diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..b533a3db7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,44 @@
+# Dependencies and local package stores
+node_modules/
+.pnpm-store/
+.npm/
+
+# Python caches and local environments
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.venv/
+venv/
+env/
+python/Lib/
+python/Scripts/
+python/Include/
+python/tcl/
+python/share/
+
+# Build outputs and temporary workspaces
+.build/
+build/
+dist/
+release/
+target/
+src-tauri/target/
+src-tauri/gen/
+
+# Local runtime data, logs, and generated output
+data/
+output/
+logs/
+*.log
+tmp/
+temp/
+CLI/**/output/
+
+# Local development records and backups
+backup/
+进度快照/
+避坑指南/
+案例/
+api文档/
diff --git a/README.md b/README.md
index 1260b13a3..9e25033ce 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
# Infinite-Canvas
-Supports comfyui/API calls/modelscope calls
+在线 API 图像、视频与对话创作工作台
+
+## Windows x64 便携版
+
+解压发布 ZIP 后运行 `Canvas.exe`。程序资源位于 `app`,所有设置、数据库、媒体、缓存和日志只写入同级 `data`;升级时先从托盘退出,再替换 `Canvas.exe` 与 `app`,不要删除 `data`。默认监听 3000 端口并允许可信局域网访问,新设备须通过托盘“配对设备”生成的一次性配对码授权。首版不支持自动更新,也不应将端口映射到公网。
配套的chrome采集插件已经上线:https://chromewebstore.google.com/detail/infinite-canvas-%E5%9B%BE%E5%83%8F%E8%A7%86%E9%A2%91%E6%96%87%E5%AD%97%E6%8A%93%E5%8F%96%E5%B7%A5/ajfhnbklbmpfaaookhfakohabnpmlcic?authuser=0&hl=en
@@ -27,9 +31,10 @@ https://www.fhl.mom/register?aff=86L574B4T2N9 (包含codex和GPT image 2模
3. 火山引擎调用(人脸认证还在修复bug)
4. Modelscope免费LLM模型和图像模型调用
5. 即梦CLI调用,可直接调用即梦高级会员的积分,支持文生图/图生图/文生视频/图生视频
-6. 支持调用本地局域网的ComfyUI
-7. 扩展图片/360全景图预览截图/视频帧抽取/循环节点等诸多功能
-8. tools文件夹中,增加了chrome批量采集到素材库的插件,PS直连画布调用所有功能的插件
+6. 扩展图片/360全景图预览截图/视频帧抽取/循环节点等诸多功能
+7. tools文件夹中,增加了chrome批量采集到素材库的插件,PS直连画布调用所有功能的插件
+8. 电商专用工作台:统一高品质生成,支持换衣、动作迁移、道具替换、角度、背景与最多 14 张角色化参考图的全能模式;素材和结果跨标签持久化,支持拖拽上传、全屏划像对比、人工质量验收与正式导出
+9. 作品管理:集中浏览生成作品,支持搜索、类型筛选、收藏、下载,并可随时使用全屏划像对比核对细节
--------
diff --git a/VERSION b/VERSION
index 2fefd2b94..b668c3b2c 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2026.06.29
\ No newline at end of file
+1.0.16
diff --git a/backend_entry.py b/backend_entry.py
new file mode 100644
index 000000000..7d44544b3
--- /dev/null
+++ b/backend_entry.py
@@ -0,0 +1,15 @@
+"""PyInstaller/Tauri sidecar entry point."""
+
+import os
+import sys
+
+ENTRY_DIR = os.path.dirname(os.path.abspath(__file__))
+if ENTRY_DIR not in sys.path:
+ sys.path.insert(0, ENTRY_DIR)
+
+from canvas_core.runtime import run_uvicorn
+from main import app
+
+
+if __name__ == "__main__":
+ run_uvicorn(app)
diff --git a/canvas-backend.spec b/canvas-backend.spec
new file mode 100644
index 000000000..4a82c284a
--- /dev/null
+++ b/canvas-backend.spec
@@ -0,0 +1,44 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+from PyInstaller.utils.hooks import collect_submodules
+
+
+hiddenimports = collect_submodules("uvicorn") + ["multipart"]
+
+a = Analysis(
+ ["backend_entry.py"],
+ pathex=["."],
+ binaries=[],
+ datas=[],
+ hiddenimports=hiddenimports,
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=["tkinter", "pytest"],
+ noarchive=False,
+ optimize=1,
+)
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ [],
+ exclude_binaries=True,
+ name="canvas-backend",
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=False,
+ console=False,
+ disable_windowed_traceback=False,
+)
+
+coll = COLLECT(
+ exe,
+ a.binaries,
+ a.datas,
+ strip=False,
+ upx=False,
+ name="canvas-backend",
+)
diff --git a/canvas_core/__init__.py b/canvas_core/__init__.py
new file mode 100644
index 000000000..d27acd0a0
--- /dev/null
+++ b/canvas_core/__init__.py
@@ -0,0 +1,2 @@
+"""Canvas desktop/runtime infrastructure shared by the web backend and host."""
+
diff --git a/canvas_core/auth.py b/canvas_core/auth.py
new file mode 100644
index 000000000..e12f28b27
--- /dev/null
+++ b/canvas_core/auth.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import secrets
+import threading
+import time
+import uuid
+from dataclasses import dataclass
+from typing import Optional
+
+from .database import CanvasDatabase
+
+
+SESSION_COOKIE = "canvas_session"
+
+
+def token_hash(token: str) -> str:
+ return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
+
+
+@dataclass(frozen=True)
+class AuthIdentity:
+ device_id: str
+ name: str
+ client_type: str
+ persistent: bool
+
+ def public(self) -> dict[str, object]:
+ return {
+ "device_id": self.device_id,
+ "name": self.name,
+ "client_type": self.client_type,
+ "persistent": self.persistent,
+ }
+
+
+class AuthManager:
+ def __init__(self, database: CanvasDatabase, desktop_token: str = "", pair_ttl_seconds: int = 300) -> None:
+ self.database = database
+ self._desktop_token = str(desktop_token or "")
+ self._pair_ttl_seconds = pair_ttl_seconds
+ self._pair_code_hash = ""
+ self._pair_expires_at = 0
+ self._runtime_sessions: dict[str, AuthIdentity] = {}
+ self._last_touches: dict[str, int] = {}
+ self._lock = threading.RLock()
+
+ @staticmethod
+ def new_token() -> str:
+ return secrets.token_urlsafe(32)
+
+ def consume_desktop_token(self, supplied: str) -> tuple[str, AuthIdentity]:
+ with self._lock:
+ expected = self._desktop_token
+ if not expected or not hmac.compare_digest(expected, str(supplied or "")):
+ raise PermissionError("桌面启动令牌无效或已使用")
+ self._desktop_token = ""
+ token = self.new_token()
+ identity = AuthIdentity("desktop-runtime", "Canvas 桌面端", "desktop", False)
+ self._runtime_sessions[token_hash(token)] = identity
+ return token, identity
+
+ def create_pair_code(self) -> tuple[str, int]:
+ code = f"{secrets.randbelow(1_000_000):06d}"
+ expires_at = int(time.time() * 1000) + self._pair_ttl_seconds * 1000
+ with self._lock:
+ self._pair_code_hash = token_hash(code)
+ self._pair_expires_at = expires_at
+ return code, expires_at
+
+ def pair(self, code: str, name: str, client_type: str = "browser") -> tuple[str, AuthIdentity]:
+ now = int(time.time() * 1000)
+ with self._lock:
+ supplied_hash = token_hash(str(code or "").strip())
+ valid = (
+ self._pair_code_hash
+ and now <= self._pair_expires_at
+ and hmac.compare_digest(self._pair_code_hash, supplied_hash)
+ )
+ if not valid:
+ raise PermissionError("配对码无效或已过期")
+ self._pair_code_hash = ""
+ self._pair_expires_at = 0
+ safe_type = str(client_type or "browser").strip().lower()
+ if safe_type not in {"browser", "chrome", "photoshop", "plugin"}:
+ safe_type = "browser"
+ safe_name = " ".join(str(name or "").split())[:80] or "已配对设备"
+ device_id = uuid.uuid4().hex
+ token = self.new_token()
+ self.database.create_paired_device(device_id, safe_name, token_hash(token), {"client_type": safe_type})
+ return token, AuthIdentity(device_id, safe_name, safe_type, True)
+
+ def authenticate(self, token: str, touch: bool = True) -> Optional[AuthIdentity]:
+ raw = str(token or "").strip()
+ if not raw:
+ return None
+ digest = token_hash(raw)
+ with self._lock:
+ runtime = self._runtime_sessions.get(digest)
+ if runtime:
+ return runtime
+ record = self.database.paired_device_by_hash(digest)
+ if not record:
+ return None
+ identity = AuthIdentity(record["id"], record["name"], record["client_type"], True)
+ if touch:
+ self._touch(identity.device_id)
+ return identity
+
+ def _touch(self, device_id: str) -> None:
+ now = int(time.time() * 1000)
+ with self._lock:
+ if now - self._last_touches.get(device_id, 0) < 60_000:
+ return
+ self._last_touches[device_id] = now
+ self.database.touch_paired_device(device_id, now)
+
+ def list_devices(self) -> list[dict[str, object]]:
+ return self.database.list_paired_devices()
+
+ def revoke(self, device_id: str) -> bool:
+ return self.database.revoke_paired_device(str(device_id or ""))
+
+ @staticmethod
+ def bearer_token(authorization: str) -> str:
+ scheme, _, value = str(authorization or "").partition(" ")
+ return value.strip() if scheme.lower() == "bearer" else ""
diff --git a/canvas_core/data_layout.py b/canvas_core/data_layout.py
new file mode 100644
index 000000000..560852fd2
--- /dev/null
+++ b/canvas_core/data_layout.py
@@ -0,0 +1,134 @@
+from __future__ import annotations
+
+import json
+import os
+import tempfile
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from .paths import AppPaths
+
+
+def atomic_write_json(path: Path, payload: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
+ handle.write("\n")
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temp_name, path)
+ except Exception:
+ try:
+ os.remove(temp_name)
+ except OSError:
+ pass
+ raise
+
+
+@dataclass(frozen=True)
+class DataLayout:
+ root: Path
+ manifest: Path
+ config: Path
+ app_config: Path
+ secret_env: Path
+ database: Path
+ database_file: Path
+ media: Path
+ media_input: Path
+ media_generated: Path
+ media_library: Path
+ media_uploads: Path
+ exports: Path
+ workflows: Path
+ workflow_custom: Path
+ workflow_overrides: Path
+ cache: Path
+ cache_previews: Path
+ cache_downloads: Path
+ logs: Path
+ run: Path
+ backups: Path
+ temp: Path
+
+ @classmethod
+ def from_app_paths(cls, paths: AppPaths) -> "DataLayout":
+ root = paths.data_root
+ config = root / "config"
+ database = root / "database"
+ media = root / "media"
+ workflows = root / "workflows"
+ cache = root / "cache"
+ return cls(
+ root=root,
+ manifest=root / "manifest.json",
+ config=config,
+ app_config=config / "app.json",
+ secret_env=config / "secrets.env",
+ database=database,
+ database_file=database / "canvas.db",
+ media=media,
+ media_input=media / "input",
+ media_generated=media / "generated",
+ media_library=media / "library",
+ media_uploads=media / "uploads",
+ exports=root / "exports",
+ workflows=workflows,
+ workflow_custom=workflows / "custom",
+ workflow_overrides=workflows / "overrides",
+ cache=cache,
+ cache_previews=cache / "previews",
+ cache_downloads=cache / "downloads",
+ logs=root / "logs",
+ run=root / "run",
+ backups=root / "backups",
+ temp=root / "temp",
+ )
+
+ def ensure(self) -> None:
+ directories = (
+ self.root,
+ self.config,
+ self.database,
+ self.media_input,
+ self.media_generated,
+ self.media_library,
+ self.media_uploads,
+ self.exports,
+ self.workflow_custom,
+ self.workflow_overrides,
+ self.cache_previews,
+ self.cache_downloads,
+ self.logs,
+ self.run,
+ self.backups,
+ self.temp,
+ )
+ for directory in directories:
+ directory.mkdir(parents=True, exist_ok=True)
+ if not self.app_config.exists():
+ atomic_write_json(
+ self.app_config,
+ {
+ "host": "0.0.0.0",
+ "port": 3000,
+ "lan_enabled": True,
+ "cache_max_bytes": 10 * 1024 * 1024 * 1024,
+ "created_at": int(time.time() * 1000),
+ },
+ )
+
+ def manifest_payload(self) -> dict[str, Any]:
+ if not self.manifest.exists():
+ return {}
+ try:
+ with self.manifest.open("r", encoding="utf-8") as handle:
+ payload = json.load(handle)
+ return payload if isinstance(payload, dict) else {}
+ except (OSError, json.JSONDecodeError):
+ return {}
+
diff --git a/canvas_core/database.py b/canvas_core/database.py
new file mode 100644
index 000000000..3f72bd8f3
--- /dev/null
+++ b/canvas_core/database.py
@@ -0,0 +1,638 @@
+from __future__ import annotations
+
+import json
+import sqlite3
+import threading
+import time
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Any, Iterable, Iterator, Optional
+
+
+def _json(value: Any) -> str:
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
+
+
+def _payload(raw: Optional[str], default: Any = None) -> Any:
+ if raw is None:
+ return default
+ try:
+ return json.loads(raw)
+ except (TypeError, json.JSONDecodeError):
+ return default
+
+
+class RevisionConflict(RuntimeError):
+ def __init__(self, revision: int, value: Any):
+ super().__init__("revision conflict")
+ self.revision = int(revision)
+ self.value = value
+
+
+class ClosingSqliteConnection(sqlite3.Connection):
+ """Make ``with connection`` close the file handle after commit/rollback."""
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ try:
+ return super().__exit__(exc_type, exc_value, traceback)
+ finally:
+ self.close()
+
+
+class CanvasDatabase:
+ SCHEMA_VERSION = 2
+
+ def __init__(self, path: Path):
+ self.path = Path(path)
+ self._schema_lock = threading.Lock()
+
+ def connect(self) -> sqlite3.Connection:
+ connection = sqlite3.connect(
+ str(self.path),
+ timeout=5.0,
+ isolation_level=None,
+ factory=ClosingSqliteConnection,
+ )
+ connection.row_factory = sqlite3.Row
+ connection.execute("PRAGMA foreign_keys=ON")
+ connection.execute("PRAGMA busy_timeout=5000")
+ connection.execute("PRAGMA synchronous=NORMAL")
+ return connection
+
+ @contextmanager
+ def transaction(self, immediate: bool = False) -> Iterator[sqlite3.Connection]:
+ connection = self.connect()
+ try:
+ connection.execute("BEGIN IMMEDIATE" if immediate else "BEGIN")
+ yield connection
+ connection.commit()
+ except Exception:
+ connection.rollback()
+ raise
+ finally:
+ connection.close()
+
+ def initialize(self) -> None:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ with self._schema_lock, self.connect() as connection:
+ connection.execute("PRAGMA journal_mode=WAL")
+ connection.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ applied_at INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS app_settings (
+ key TEXT PRIMARY KEY,
+ value_json TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1,
+ updated_at INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS providers (
+ id TEXT PRIMARY KEY,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ payload_json TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1,
+ updated_at INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS projects (
+ id TEXT PRIMARY KEY,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ payload_json TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1,
+ updated_at INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS canvases (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL DEFAULT 'default',
+ kind TEXT NOT NULL DEFAULT 'classic',
+ title TEXT NOT NULL DEFAULT '',
+ updated_at INTEGER NOT NULL,
+ deleted_at INTEGER NOT NULL DEFAULT 0,
+ revision INTEGER NOT NULL DEFAULT 1,
+ payload_json TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_canvases_project ON canvases(project_id, deleted_at, updated_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_canvases_kind ON canvases(kind, deleted_at, updated_at DESC);
+ CREATE TABLE IF NOT EXISTS conversations (
+ id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT '',
+ updated_at INTEGER NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1,
+ payload_json TEXT NOT NULL,
+ PRIMARY KEY(user_id, id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id, updated_at DESC);
+ CREATE TABLE IF NOT EXISTS generation_history (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL DEFAULT 'zimage',
+ created_at REAL NOT NULL,
+ payload_json TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_generation_history_kind ON generation_history(kind, created_at DESC);
+ CREATE TABLE IF NOT EXISTS tasks (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT '',
+ updated_at REAL NOT NULL,
+ payload_json TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_tasks_kind_status ON tasks(kind, status, updated_at DESC);
+ CREATE TABLE IF NOT EXISTS library_documents (
+ kind TEXT PRIMARY KEY,
+ payload_json TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1,
+ updated_at INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS shared_folders (
+ id TEXT PRIMARY KEY,
+ payload_json TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS paired_devices (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ created_at INTEGER NOT NULL,
+ last_seen_at INTEGER NOT NULL,
+ revoked_at INTEGER NOT NULL DEFAULT 0,
+ payload_json TEXT NOT NULL DEFAULT '{}'
+ );
+ CREATE TABLE IF NOT EXISTS kv_documents (
+ namespace TEXT NOT NULL,
+ key TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY(namespace, key)
+ );
+ CREATE TABLE IF NOT EXISTS secret_values (
+ key TEXT PRIMARY KEY,
+ encrypted_value BLOB NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ """
+ )
+ connection.execute(
+ "INSERT OR IGNORE INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)",
+ (1, "initial-desktop-schema", int(time.time() * 1000)),
+ )
+ connection.execute(
+ "INSERT OR IGNORE INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)",
+ (2, "dpapi-secrets-and-device-auth", int(time.time() * 1000)),
+ )
+
+ def pragma_summary(self) -> dict[str, Any]:
+ with self.connect() as connection:
+ return {
+ "journal_mode": connection.execute("PRAGMA journal_mode").fetchone()[0],
+ "foreign_keys": connection.execute("PRAGMA foreign_keys").fetchone()[0],
+ "busy_timeout": connection.execute("PRAGMA busy_timeout").fetchone()[0],
+ "synchronous": connection.execute("PRAGMA synchronous").fetchone()[0],
+ "schema_version": self.SCHEMA_VERSION,
+ }
+
+ def get_document(self, namespace: str, key: str, default: Any = None) -> Any:
+ with self.connect() as connection:
+ row = connection.execute(
+ "SELECT payload_json FROM kv_documents WHERE namespace=? AND key=?", (namespace, key)
+ ).fetchone()
+ return _payload(row["payload_json"], default) if row else default
+
+ def put_document(self, namespace: str, key: str, value: Any) -> int:
+ now = int(time.time() * 1000)
+ with self.transaction(immediate=True) as connection:
+ row = connection.execute(
+ "SELECT revision FROM kv_documents WHERE namespace=? AND key=?", (namespace, key)
+ ).fetchone()
+ revision = int(row["revision"] if row else 0) + 1
+ connection.execute(
+ """INSERT INTO kv_documents(namespace,key,payload_json,revision,updated_at) VALUES(?,?,?,?,?)
+ ON CONFLICT(namespace,key) DO UPDATE SET payload_json=excluded.payload_json,
+ revision=excluded.revision,updated_at=excluded.updated_at""",
+ (namespace, key, _json(value), revision, now),
+ )
+ return revision
+
+ def get_setting(self, key: str, default: Any = None) -> dict[str, Any]:
+ with self.connect() as connection:
+ row = connection.execute(
+ "SELECT value_json,revision,updated_at FROM app_settings WHERE key=?", (key,)
+ ).fetchone()
+ if not row:
+ return {"value": default, "revision": 0, "updated_at": 0}
+ return {
+ "value": _payload(row["value_json"], default),
+ "revision": int(row["revision"]),
+ "updated_at": int(row["updated_at"]),
+ }
+
+ def save_setting(self, key: str, value: Any, base_revision: int = 0, only_if_empty: bool = False) -> dict[str, Any]:
+ now = int(time.time() * 1000)
+ with self.transaction(immediate=True) as connection:
+ row = connection.execute(
+ "SELECT value_json,revision,updated_at FROM app_settings WHERE key=?", (key,)
+ ).fetchone()
+ current_revision = int(row["revision"] if row else 0)
+ current_value = _payload(row["value_json"], None) if row else None
+ if only_if_empty and row and current_value not in (None, {}, [], ""):
+ return {"value": current_value, "revision": current_revision, "updated_at": int(row["updated_at"])}
+ if base_revision and base_revision != current_revision:
+ raise RevisionConflict(current_revision, current_value)
+ revision = current_revision + 1
+ connection.execute(
+ """INSERT INTO app_settings(key,value_json,revision,updated_at) VALUES(?,?,?,?)
+ ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,
+ revision=excluded.revision,updated_at=excluded.updated_at""",
+ (key, _json(value), revision, now),
+ )
+ return {"value": value, "revision": revision, "updated_at": now}
+
+ def next_revision(self, topic: str, entity_id: str = "global") -> int:
+ key = f"_revision:{topic}:{entity_id}"
+ with self.transaction(immediate=True) as connection:
+ row = connection.execute("SELECT revision FROM app_settings WHERE key=?", (key,)).fetchone()
+ revision = int(row["revision"] if row else 0) + 1
+ connection.execute(
+ """INSERT INTO app_settings(key,value_json,revision,updated_at) VALUES(?,?,?,?)
+ ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,
+ revision=excluded.revision,updated_at=excluded.updated_at""",
+ (key, _json({"topic": topic, "entity_id": entity_id}), revision, int(time.time() * 1000)),
+ )
+ return revision
+
+ def save_secret_blob(self, key: str, encrypted_value: bytes) -> None:
+ with self.transaction(immediate=True) as connection:
+ connection.execute(
+ """INSERT INTO secret_values(key,encrypted_value,updated_at) VALUES(?,?,?)
+ ON CONFLICT(key) DO UPDATE SET encrypted_value=excluded.encrypted_value,
+ updated_at=excluded.updated_at""",
+ (key, sqlite3.Binary(encrypted_value), int(time.time() * 1000)),
+ )
+
+ def load_secret_blob(self, key: str) -> Optional[bytes]:
+ with self.connect() as connection:
+ row = connection.execute("SELECT encrypted_value FROM secret_values WHERE key=?", (key,)).fetchone()
+ return bytes(row["encrypted_value"]) if row else None
+
+ def list_secret_blobs(self) -> dict[str, bytes]:
+ with self.connect() as connection:
+ rows = connection.execute("SELECT key,encrypted_value FROM secret_values ORDER BY key").fetchall()
+ return {str(row["key"]): bytes(row["encrypted_value"]) for row in rows}
+
+ def delete_secret(self, key: str) -> None:
+ with self.transaction(immediate=True) as connection:
+ connection.execute("DELETE FROM secret_values WHERE key=?", (key,))
+
+ def create_paired_device(self, device_id: str, name: str, token_hash: str, payload: dict[str, Any]) -> None:
+ now = int(time.time() * 1000)
+ with self.transaction(immediate=True) as connection:
+ connection.execute(
+ """INSERT INTO paired_devices(id,name,token_hash,created_at,last_seen_at,revoked_at,payload_json)
+ VALUES(?,?,?,?,?,0,?)""",
+ (device_id, name, token_hash, now, now, _json(payload or {})),
+ )
+
+ def paired_device_by_hash(self, token_hash: str) -> Optional[dict[str, Any]]:
+ with self.connect() as connection:
+ row = connection.execute(
+ "SELECT * FROM paired_devices WHERE token_hash=? AND revoked_at=0", (token_hash,)
+ ).fetchone()
+ return self._paired_device_record(row) if row else None
+
+ def paired_device(self, device_id: str) -> Optional[dict[str, Any]]:
+ with self.connect() as connection:
+ row = connection.execute("SELECT * FROM paired_devices WHERE id=?", (device_id,)).fetchone()
+ return self._paired_device_record(row) if row else None
+
+ def list_paired_devices(self, include_revoked: bool = False) -> list[dict[str, Any]]:
+ query = "SELECT * FROM paired_devices" if include_revoked else "SELECT * FROM paired_devices WHERE revoked_at=0"
+ with self.connect() as connection:
+ rows = connection.execute(query + " ORDER BY created_at DESC").fetchall()
+ return [self._paired_device_record(row) for row in rows]
+
+ def touch_paired_device(self, device_id: str, timestamp: Optional[int] = None) -> None:
+ with self.transaction(immediate=True) as connection:
+ connection.execute(
+ "UPDATE paired_devices SET last_seen_at=? WHERE id=? AND revoked_at=0",
+ (int(timestamp or time.time() * 1000), device_id),
+ )
+
+ def revoke_paired_device(self, device_id: str) -> bool:
+ with self.transaction(immediate=True) as connection:
+ cursor = connection.execute(
+ "UPDATE paired_devices SET revoked_at=? WHERE id=? AND revoked_at=0",
+ (int(time.time() * 1000), device_id),
+ )
+ return cursor.rowcount > 0
+
+ @staticmethod
+ def _paired_device_record(row: sqlite3.Row) -> dict[str, Any]:
+ payload = _payload(row["payload_json"], {})
+ return {
+ "id": str(row["id"]),
+ "name": str(row["name"]),
+ "created_at": int(row["created_at"]),
+ "last_seen_at": int(row["last_seen_at"]),
+ "revoked_at": int(row["revoked_at"]),
+ "client_type": str((payload or {}).get("client_type") or "browser"),
+ }
+
+ def load_providers(self) -> list[dict[str, Any]]:
+ with self.connect() as connection:
+ rows = connection.execute("SELECT payload_json FROM providers ORDER BY sort_order,id").fetchall()
+ return [_payload(row["payload_json"], {}) for row in rows]
+
+ def save_providers(self, providers: Iterable[dict[str, Any]]) -> None:
+ now = int(time.time() * 1000)
+ values = list(providers)
+ with self.transaction(immediate=True) as connection:
+ connection.execute("DELETE FROM providers")
+ for index, provider in enumerate(values):
+ connection.execute(
+ "INSERT INTO providers(id,sort_order,enabled,payload_json,updated_at) VALUES(?,?,?,?,?)",
+ (str(provider.get("id") or f"provider-{index}"), index, int(provider.get("enabled", True)), _json(provider), now),
+ )
+
+ def load_projects(self) -> list[dict[str, Any]]:
+ with self.connect() as connection:
+ rows = connection.execute("SELECT payload_json FROM projects ORDER BY sort_order,updated_at").fetchall()
+ return [_payload(row["payload_json"], {}) for row in rows]
+
+ def save_projects(self, projects: Iterable[dict[str, Any]]) -> None:
+ now = int(time.time() * 1000)
+ values = list(projects)
+ with self.transaction(immediate=True) as connection:
+ connection.execute("DELETE FROM projects")
+ for index, project in enumerate(values):
+ connection.execute(
+ "INSERT INTO projects(id,sort_order,payload_json,updated_at) VALUES(?,?,?,?)",
+ (str(project.get("id") or f"project-{index}"), int(project.get("order") or index), _json(project), now),
+ )
+
+ def save_canvas(self, canvas: dict[str, Any], touch: bool = True) -> dict[str, Any]:
+ value = dict(canvas)
+ now = int(time.time() * 1000)
+ if touch:
+ value["updated_at"] = now
+ else:
+ value["updated_at"] = int(value.get("updated_at") or now)
+ with self.transaction(immediate=True) as connection:
+ row = connection.execute("SELECT revision FROM canvases WHERE id=?", (value["id"],)).fetchone()
+ revision = int(row["revision"] if row else 0) + 1
+ value["revision"] = revision
+ connection.execute(
+ """INSERT INTO canvases(id,project_id,kind,title,updated_at,deleted_at,revision,payload_json)
+ VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
+ project_id=excluded.project_id,kind=excluded.kind,title=excluded.title,
+ updated_at=excluded.updated_at,deleted_at=excluded.deleted_at,
+ revision=excluded.revision,payload_json=excluded.payload_json""",
+ (
+ value["id"], str(value.get("project") or "default"), str(value.get("kind") or "classic"),
+ str(value.get("title") or ""), int(value["updated_at"]), int(value.get("deleted_at") or 0),
+ revision, _json(value),
+ ),
+ )
+ canvas.clear()
+ canvas.update(value)
+ return canvas
+
+ def get_canvas(self, canvas_id: str) -> Optional[dict[str, Any]]:
+ with self.connect() as connection:
+ row = connection.execute("SELECT payload_json FROM canvases WHERE id=?", (canvas_id,)).fetchone()
+ return _payload(row["payload_json"], None) if row else None
+
+ def list_canvases(self, include_deleted: Optional[bool] = None) -> list[dict[str, Any]]:
+ query = "SELECT payload_json FROM canvases"
+ params: tuple[Any, ...] = ()
+ if include_deleted is True:
+ query += " WHERE deleted_at>0"
+ elif include_deleted is False:
+ query += " WHERE deleted_at=0"
+ query += " ORDER BY updated_at DESC"
+ with self.connect() as connection:
+ rows = connection.execute(query, params).fetchall()
+ return [_payload(row["payload_json"], {}) for row in rows]
+
+ def purge_canvas(self, canvas_id: str) -> None:
+ with self.transaction(immediate=True) as connection:
+ connection.execute("DELETE FROM canvases WHERE id=?", (canvas_id,))
+
+ def reassign_project(self, project_id: str, target_project_id: str) -> int:
+ moved = 0
+ for canvas in self.list_canvases(include_deleted=None):
+ if str(canvas.get("project") or "") == project_id:
+ canvas["project"] = target_project_id
+ self.save_canvas(canvas, touch=False)
+ moved += 1
+ return moved
+
+ def save_conversation(self, user_id: str, conversation: dict[str, Any]) -> None:
+ now = int(conversation.get("updated_at") or time.time() * 1000)
+ with self.transaction(immediate=True) as connection:
+ row = connection.execute(
+ "SELECT revision FROM conversations WHERE user_id=? AND id=?", (user_id, conversation["id"])
+ ).fetchone()
+ revision = int(row["revision"] if row else 0) + 1
+ connection.execute(
+ """INSERT INTO conversations(id,user_id,title,updated_at,revision,payload_json) VALUES(?,?,?,?,?,?)
+ ON CONFLICT(user_id,id) DO UPDATE SET title=excluded.title,updated_at=excluded.updated_at,
+ revision=excluded.revision,payload_json=excluded.payload_json""",
+ (conversation["id"], user_id, str(conversation.get("title") or ""), now, revision, _json(conversation)),
+ )
+
+ def get_conversation(self, user_id: str, conversation_id: str) -> Optional[dict[str, Any]]:
+ with self.connect() as connection:
+ row = connection.execute(
+ "SELECT payload_json FROM conversations WHERE user_id=? AND id=?", (user_id, conversation_id)
+ ).fetchone()
+ return _payload(row["payload_json"], None) if row else None
+
+ def list_conversations(self, user_id: str) -> list[dict[str, Any]]:
+ with self.connect() as connection:
+ rows = connection.execute(
+ "SELECT payload_json FROM conversations WHERE user_id=? ORDER BY updated_at DESC", (user_id,)
+ ).fetchall()
+ return [_payload(row["payload_json"], {}) for row in rows]
+
+ def delete_conversation(self, user_id: str, conversation_id: str) -> None:
+ with self.transaction(immediate=True) as connection:
+ connection.execute("DELETE FROM conversations WHERE user_id=? AND id=?", (user_id, conversation_id))
+
+ def prepend_history(self, record: dict[str, Any], limit: int = 5000) -> None:
+ timestamp = float(record.get("timestamp") or time.time())
+ record["timestamp"] = timestamp
+ history_id = str(record.get("id") or f"{timestamp:.6f}-{abs(hash(_json(record))) & 0xFFFFFFFF:08x}")
+ with self.transaction(immediate=True) as connection:
+ connection.execute(
+ "INSERT OR REPLACE INTO generation_history(id,kind,created_at,payload_json) VALUES(?,?,?,?)",
+ (history_id, str(record.get("type") or "zimage"), timestamp, _json(record)),
+ )
+ connection.execute(
+ "DELETE FROM generation_history WHERE id IN (SELECT id FROM generation_history ORDER BY created_at DESC LIMIT -1 OFFSET ?)",
+ (limit,),
+ )
+
+ def list_history(self, kind: str = "") -> list[dict[str, Any]]:
+ query = "SELECT id,payload_json FROM generation_history"
+ params: tuple[Any, ...] = ()
+ if kind:
+ query += " WHERE kind=?"
+ params = (kind,)
+ query += " ORDER BY created_at DESC"
+ with self.connect() as connection:
+ rows = connection.execute(query, params).fetchall()
+ records = []
+ for row in rows:
+ payload = _payload(row["payload_json"], {})
+ if not isinstance(payload, dict):
+ payload = {}
+ payload.setdefault("_history_id", str(row["id"]))
+ records.append(payload)
+ return records
+
+ def delete_history_timestamp(self, timestamp: float) -> Optional[dict[str, Any]]:
+ with self.transaction(immediate=True) as connection:
+ row = connection.execute(
+ "SELECT id,payload_json FROM generation_history WHERE ABS(created_at-?)<0.001 ORDER BY created_at DESC LIMIT 1",
+ (float(timestamp),),
+ ).fetchone()
+ if row:
+ item = _payload(row["payload_json"], {})
+ connection.execute("DELETE FROM generation_history WHERE id=?", (row["id"],))
+ return item
+ return None
+
+ def get_library(self, kind: str, default: Any = None) -> Any:
+ with self.connect() as connection:
+ row = connection.execute("SELECT payload_json FROM library_documents WHERE kind=?", (kind,)).fetchone()
+ return _payload(row["payload_json"], default) if row else default
+
+ def save_library(self, kind: str, value: Any) -> int:
+ now = int(time.time() * 1000)
+ with self.transaction(immediate=True) as connection:
+ row = connection.execute("SELECT revision FROM library_documents WHERE kind=?", (kind,)).fetchone()
+ revision = int(row["revision"] if row else 0) + 1
+ connection.execute(
+ """INSERT INTO library_documents(kind,payload_json,revision,updated_at) VALUES(?,?,?,?)
+ ON CONFLICT(kind) DO UPDATE SET payload_json=excluded.payload_json,
+ revision=excluded.revision,updated_at=excluded.updated_at""",
+ (kind, _json(value), revision, now),
+ )
+ return revision
+
+ def save_tasks(self, kind: str, tasks: Iterable[dict[str, Any]]) -> None:
+ values = list(tasks)
+ with self.transaction(immediate=True) as connection:
+ connection.execute("DELETE FROM tasks WHERE kind=?", (kind,))
+ for task in values:
+ task_id = str(task.get("id") or task.get("task_id") or "")
+ if not task_id:
+ continue
+ connection.execute(
+ "INSERT INTO tasks(id,kind,status,updated_at,payload_json) VALUES(?,?,?,?,?)",
+ (task_id, kind, str(task.get("status") or ""), float(task.get("updated_at") or time.time()), _json(task)),
+ )
+
+ def upsert_task(self, kind: str, task: dict[str, Any]) -> None:
+ task_id = str(task.get("id") or task.get("task_id") or "")
+ if not task_id:
+ return
+ updated_at = task.get("updated_at")
+ if updated_at is None:
+ updated_at = time.time()
+ with self.transaction(immediate=True) as connection:
+ connection.execute(
+ """INSERT INTO tasks(id,kind,status,updated_at,payload_json) VALUES(?,?,?,?,?)
+ ON CONFLICT(id) DO UPDATE SET kind=excluded.kind,status=excluded.status,
+ updated_at=excluded.updated_at,payload_json=excluded.payload_json""",
+ (task_id, kind, str(task.get("status") or ""), float(updated_at), _json(task)),
+ )
+
+ def prune_tasks(self, kind: str, keep: int = 5000) -> int:
+ safe_keep = max(1, int(keep or 1))
+ with self.transaction(immediate=True) as connection:
+ before = int(connection.execute("SELECT COUNT(*) FROM tasks WHERE kind=?", (kind,)).fetchone()[0])
+ connection.execute(
+ """DELETE FROM tasks WHERE kind=? AND id NOT IN (
+ SELECT id FROM tasks WHERE kind=? ORDER BY updated_at DESC LIMIT ?
+ )""",
+ (kind, kind, safe_keep),
+ )
+ return max(0, before - safe_keep)
+
+ def load_tasks(self, kind: str) -> list[dict[str, Any]]:
+ with self.connect() as connection:
+ rows = connection.execute("SELECT payload_json FROM tasks WHERE kind=? ORDER BY updated_at DESC", (kind,)).fetchall()
+ return [_payload(row["payload_json"], {}) for row in rows]
+
+ def counts(self) -> dict[str, int]:
+ tables = ("providers", "projects", "canvases", "conversations", "generation_history", "tasks", "library_documents")
+ with self.connect() as connection:
+ return {table: int(connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) for table in tables}
+
+ def import_legacy(self, snapshot: dict[str, Any]) -> None:
+ with self.transaction(immediate=True) as connection:
+ now = int(time.time() * 1000)
+ if not connection.execute("SELECT 1 FROM providers LIMIT 1").fetchone():
+ for index, provider in enumerate(snapshot.get("providers") or []):
+ if isinstance(provider, dict) and provider.get("id"):
+ connection.execute(
+ "INSERT OR IGNORE INTO providers(id,sort_order,enabled,payload_json,updated_at) VALUES(?,?,?,?,?)",
+ (provider["id"], index, int(provider.get("enabled", True)), _json(provider), now),
+ )
+ if not connection.execute("SELECT 1 FROM projects LIMIT 1").fetchone():
+ for index, project in enumerate(snapshot.get("projects") or []):
+ if isinstance(project, dict) and project.get("id"):
+ connection.execute(
+ "INSERT OR IGNORE INTO projects(id,sort_order,payload_json,updated_at) VALUES(?,?,?,?)",
+ (project["id"], int(project.get("order") or index), _json(project), now),
+ )
+ if not connection.execute("SELECT 1 FROM canvases LIMIT 1").fetchone():
+ for canvas in snapshot.get("canvases") or []:
+ if not isinstance(canvas, dict) or not canvas.get("id"):
+ continue
+ canvas.setdefault("revision", 1)
+ connection.execute(
+ "INSERT OR IGNORE INTO canvases(id,project_id,kind,title,updated_at,deleted_at,revision,payload_json) VALUES(?,?,?,?,?,?,?,?)",
+ (canvas["id"], str(canvas.get("project") or "default"), str(canvas.get("kind") or "classic"),
+ str(canvas.get("title") or ""), int(canvas.get("updated_at") or now), int(canvas.get("deleted_at") or 0),
+ int(canvas.get("revision") or 1), _json(canvas)),
+ )
+ if not connection.execute("SELECT 1 FROM conversations LIMIT 1").fetchone():
+ for user_id, conversation in snapshot.get("conversations") or []:
+ if isinstance(conversation, dict) and conversation.get("id"):
+ connection.execute(
+ "INSERT OR IGNORE INTO conversations(id,user_id,title,updated_at,payload_json) VALUES(?,?,?,?,?)",
+ (conversation["id"], user_id, str(conversation.get("title") or ""), int(conversation.get("updated_at") or now), _json(conversation)),
+ )
+ if not connection.execute("SELECT 1 FROM generation_history LIMIT 1").fetchone():
+ for index, record in enumerate(snapshot.get("history") or []):
+ if not isinstance(record, dict):
+ continue
+ created = float(record.get("timestamp") or (time.time() - index / 1000))
+ history_id = f"legacy-{index}-{created:.6f}"
+ connection.execute(
+ "INSERT OR IGNORE INTO generation_history(id,kind,created_at,payload_json) VALUES(?,?,?,?)",
+ (history_id, str(record.get("type") or "zimage"), created, _json(record)),
+ )
+ libraries = snapshot.get("libraries") or {}
+ for kind, value in libraries.items():
+ if value is not None:
+ connection.execute(
+ "INSERT OR IGNORE INTO library_documents(kind,payload_json,updated_at) VALUES(?,?,?)",
+ (kind, _json(value), now),
+ )
+ if not connection.execute("SELECT 1 FROM tasks LIMIT 1").fetchone():
+ for task in snapshot.get("online_image_tasks") or []:
+ if not isinstance(task, dict):
+ continue
+ task_id = str(task.get("id") or task.get("task_id") or "")
+ if task_id:
+ connection.execute(
+ "INSERT OR IGNORE INTO tasks(id,kind,status,updated_at,payload_json) VALUES(?,?,?,?,?)",
+ (task_id, "online_image", str(task.get("status") or ""), float(task.get("updated_at") or time.time()), _json(task)),
+ )
diff --git a/canvas_core/ecommerce.py b/canvas_core/ecommerce.py
new file mode 100644
index 000000000..936e4f9f1
--- /dev/null
+++ b/canvas_core/ecommerce.py
@@ -0,0 +1,780 @@
+"""Pure contracts, routing and prompts for the e-commerce image workspace."""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import Any, Iterable
+
+
+OPERATIONS = (
+ "try_on",
+ "pose_transfer",
+ "prop_replace",
+ "angle_change",
+ "background_change",
+ "universal",
+)
+MODES = ("standard",)
+LEGACY_MODES = {"preview", "publish"}
+ASPECT_RATIOS = ("source", "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "9:16", "16:9")
+RESOLUTIONS = ("auto", "1k", "2k", "4k")
+QUALITIES = ("auto", "low", "medium", "high")
+
+SIZE_PRESETS: dict[str, dict[str, str]] = {
+ "1:1": {"1k": "1024x1024", "2k": "2048x2048", "4k": "2880x2880"},
+ "2:3": {"1k": "1024x1536", "2k": "1360x2040", "4k": "2304x3456"},
+ "3:2": {"1k": "1536x1024", "2k": "2040x1360", "4k": "3456x2304"},
+ "3:4": {"1k": "1008x1344", "2k": "1536x2048", "4k": "2400x3200"},
+ "4:3": {"1k": "1344x1008", "2k": "2048x1536", "4k": "3200x2400"},
+ "4:5": {"1k": "1024x1280", "2k": "1632x2040", "4k": "2560x3200"},
+ "9:16": {"1k": "720x1280", "2k": "1152x2048", "4k": "2160x3840"},
+ "16:9": {"1k": "1280x720", "2k": "2048x1152", "4k": "3840x2160"},
+}
+
+OPERATION_INPUTS: dict[str, tuple[str, ...]] = {
+ "try_on": ("source", "garment"),
+ "pose_transfer": ("source",),
+ "prop_replace": ("source", "prop"),
+ "angle_change": ("source",),
+ "background_change": ("source",),
+ "universal": (),
+}
+UNIVERSAL_REFERENCE_LIMIT = 14
+UNIVERSAL_REFERENCE_ROLES = [
+ {"id": "subject", "label": {"zh": "主体/模特", "en": "Subject / model"}},
+ {"id": "upper_garment", "label": {"zh": "上装", "en": "Upper garment"}},
+ {"id": "lower_garment", "label": {"zh": "下装", "en": "Lower garment"}},
+ {"id": "full_garment", "label": {"zh": "连衣裙/套装", "en": "Dress / full outfit"}},
+ {"id": "shoes", "label": {"zh": "鞋靴", "en": "Shoes"}},
+ {"id": "accessory", "label": {"zh": "首饰/配饰", "en": "Accessory"}},
+ {"id": "prop", "label": {"zh": "道具/商品", "en": "Prop / product"}},
+ {"id": "pose", "label": {"zh": "动作参考", "en": "Pose reference"}},
+ {"id": "scene", "label": {"zh": "场景/背景", "en": "Scene / background"}},
+ {"id": "style", "label": {"zh": "风格/光影", "en": "Style / lighting"}},
+]
+UNIVERSAL_REFERENCE_ROLE_IDS = {item["id"] for item in UNIVERSAL_REFERENCE_ROLES}
+ALLOWED_INPUT_ROLES = {"source", "garment", "pose", "prop", "background", "mask", *UNIVERSAL_REFERENCE_ROLE_IDS}
+UNIVERSAL_INTERACTIONS = {"wear", "put_on", "hold", "carry", "place", "use", "pose", "scene", "style", "identity"}
+ACCESSORY_WEAR_KEYWORDS = (
+ "necklace", "项链", "earring", "耳环", "bracelet", "手链", "bangle", "手镯", "ring", "戒指",
+ "watch", "手表", "hat", "帽", "cap", "glasses", "眼镜", "sunglasses", "墨镜", "belt", "腰带",
+ "scarf", "围巾", "brooch", "胸针", "tie", "领带",
+)
+HANDHELD_KEYWORDS = (
+ "phone", "手机", "smartphone", "camera", "相机", "cup", "杯", "bottle", "瓶", "book", "书",
+ "umbrella", "伞", "flower", "花", "wallet", "钱包", "card", "卡", "cosmetic", "口红", "lipstick",
+)
+BAG_KEYWORDS = ("bag", "包", "handbag", "tote", "clutch", "purse", "satchel", "backpack", "shoulder bag", "手提包", "托特包", "双肩包", "挎包")
+PLACED_PROP_KEYWORDS = ("chair", "椅", "sofa", "沙发", "table", "桌", "vase", "花瓶", "lamp", "灯", "plant", "植物", "furniture", "家具", "decor", "摆件")
+
+POSE_PRESETS = [
+ {"id": "standing_front", "label": {"zh": "正面站立", "en": "Front standing"}, "prompt": "standing upright, front view, arms relaxed naturally"},
+ {"id": "standing_three_quarter", "label": {"zh": "四分之三站姿", "en": "Three-quarter"}, "prompt": "three-quarter standing pose with a natural weight shift"},
+ {"id": "side_profile", "label": {"zh": "侧身站立", "en": "Side profile"}, "prompt": "clean side-profile standing pose"},
+ {"id": "walking", "label": {"zh": "自然行走", "en": "Walking"}, "prompt": "natural mid-step walking pose with realistic balance"},
+ {"id": "sitting", "label": {"zh": "自然坐姿", "en": "Sitting"}, "prompt": "natural seated pose with anatomically correct limbs"},
+ {"id": "arms_crossed", "label": {"zh": "双臂交叉", "en": "Arms crossed"}, "prompt": "standing with arms crossed naturally"},
+ {"id": "hand_on_hip", "label": {"zh": "单手叉腰", "en": "Hand on hip"}, "prompt": "standing with one hand on the hip, confident catalog pose"},
+ {"id": "product_hold", "label": {"zh": "手持商品", "en": "Holding product"}, "prompt": "balanced standing pose holding a product naturally at chest level"},
+]
+
+BACKGROUND_PRESETS = [
+ {"id": "studio_white", "label": {"zh": "纯白棚拍", "en": "White studio"}, "prompt": "seamless pure white e-commerce studio background, soft grounded shadow"},
+ {"id": "studio_gray", "label": {"zh": "中性灰棚拍", "en": "Gray studio"}, "prompt": "neutral light-gray studio cyclorama, soft commercial lighting"},
+ {"id": "warm_minimal", "label": {"zh": "暖色极简", "en": "Warm minimal"}, "prompt": "warm minimal beige set, refined natural materials, soft daylight"},
+ {"id": "luxury_dark", "label": {"zh": "深色奢华", "en": "Luxury dark"}, "prompt": "premium dark studio set with controlled highlights and elegant reflections"},
+ {"id": "home_lifestyle", "label": {"zh": "居家生活", "en": "Home lifestyle"}, "prompt": "tasteful modern home lifestyle scene, natural window light"},
+ {"id": "outdoor_daylight", "label": {"zh": "户外日光", "en": "Outdoor daylight"}, "prompt": "clean outdoor lifestyle scene in soft natural daylight"},
+ {"id": "festival_red", "label": {"zh": "节庆红金", "en": "Festive red"}, "prompt": "refined festive red and gold commercial set, tasteful and uncluttered"},
+ {"id": "transparent_style", "label": {"zh": "透明底观感", "en": "Cutout style"}, "prompt": "isolated clean catalog presentation with no visible environment and a subtle contact shadow"},
+]
+
+QUALITY_CHECKS: dict[str, list[dict[str, Any]]] = {
+ "try_on": [
+ {"id": "identity", "label": {"zh": "人物脸部、发型、体型和肤色与原图一致", "en": "Face, hair, body shape, and skin tone match the source"}},
+ {"id": "garment", "label": {"zh": "服装版型、颜色、面料、图案、Logo 和文字准确", "en": "Garment cut, color, fabric, pattern, logo, and text are accurate"}},
+ {"id": "anatomy", "label": {"zh": "四肢、手指、衣褶和遮挡关系自然", "en": "Limbs, fingers, folds, and occlusions look natural"}},
+ {"id": "background", "label": {"zh": "姿态、镜头、光线和背景未被意外修改", "en": "Pose, camera, lighting, and background were not changed unexpectedly"}},
+ {"id": "artifacts", "label": {"zh": "放大检查后无破损、重影、水印或额外物体", "en": "No damage, ghosting, watermark, or extra objects at full size"}},
+ ],
+ "pose_transfer": [
+ {"id": "identity", "label": {"zh": "人物身份、脸部和体型保持一致", "en": "Identity, face, and body shape are preserved"}},
+ {"id": "outfit", "label": {"zh": "原服装、配饰、图案和文字保持一致", "en": "Original outfit, accessories, patterns, and text are preserved"}},
+ {"id": "pose", "label": {"zh": "目标动作迁移正确且重心合理", "en": "Target pose is transferred with believable balance"}},
+ {"id": "anatomy", "label": {"zh": "关节、手脚和遮挡关系符合人体结构", "en": "Joints, hands, feet, and occlusions are anatomically valid"}},
+ {"id": "scene", "label": {"zh": "背景、镜头与光线没有非预期变化", "en": "Background, camera, and lighting have no unintended changes"}},
+ ],
+ "prop_replace": [
+ {"id": "prop", "label": {"zh": "新道具的造型、材质、颜色、Logo 和文字准确", "en": "New prop shape, material, color, logo, and text are accurate"}},
+ {"id": "placement", "label": {"zh": "尺寸、透视、握持或接触关系合理", "en": "Scale, perspective, grip, and contact are believable"}},
+ {"id": "lighting", "label": {"zh": "道具光线、阴影和反射与场景匹配", "en": "Lighting, shadow, and reflections match the scene"}},
+ {"id": "preservation", "label": {"zh": "替换区域以外的人物、商品和背景保持不变", "en": "People, products, and background outside the target are preserved"}},
+ {"id": "artifacts", "label": {"zh": "边缘自然,无残留旧道具、重影或水印", "en": "Edges are clean with no old prop remnants, ghosting, or watermark"}},
+ ],
+ "angle_change": [
+ {"id": "identity", "label": {"zh": "主体身份、商品结构和比例保持一致", "en": "Subject identity, product structure, and proportions are preserved"}},
+ {"id": "details", "label": {"zh": "颜色、材质、Logo、文字与关键细节准确", "en": "Color, material, logo, text, and key details are accurate"}},
+ {"id": "view", "label": {"zh": "水平角、俯仰角和景别符合选择", "en": "Azimuth, elevation, and distance match the controls"}},
+ {"id": "geometry", "label": {"zh": "新露出的表面合理,无镜像、复制或结构畸变", "en": "Newly revealed surfaces are plausible with no mirroring or deformation"}},
+ {"id": "scene", "label": {"zh": "非目标场景、光线和背景保持一致", "en": "Non-target scene, lighting, and background remain consistent"}},
+ ],
+ "background_change": [
+ {"id": "foreground", "label": {"zh": "人物或商品主体、Logo、文字和颜色保持准确", "en": "Foreground subject, logo, text, and color remain accurate"}},
+ {"id": "edges", "label": {"zh": "头发、透明材质和商品边缘无白边或缺损", "en": "Hair, transparent materials, and edges have no halos or damage"}},
+ {"id": "scene", "label": {"zh": "背景内容符合所选模板、描述或参考图", "en": "Background matches the selected preset, prompt, or reference"}},
+ {"id": "lighting", "label": {"zh": "接触阴影、反射、景深和光线方向自然", "en": "Contact shadow, reflections, depth, and light direction are natural"}},
+ {"id": "artifacts", "label": {"zh": "无额外主体、文字、水印或明显生成瑕疵", "en": "No extra subjects, text, watermark, or visible generation defects"}},
+ ],
+ "universal": [
+ {"id": "identity", "label": {"zh": "主体身份、脸部、发型、体型和肤色只来自主体参考图", "en": "Identity, face, hair, body shape, and skin tone come only from subject references"}},
+ {"id": "products", "label": {"zh": "服装、鞋、配饰和道具的版型、材质、颜色、Logo 与文字准确", "en": "Garments, shoes, accessories, and props preserve shape, material, color, logos, and text"}},
+ {"id": "pose", "label": {"zh": "动作只迁移姿态与关节关系,人体结构和重心自然", "en": "Pose transfers only posture and joints with natural anatomy and balance"}},
+ {"id": "scene", "label": {"zh": "场景构图、光线、透视和接触阴影协调", "en": "Scene composition, lighting, perspective, and contact shadows are coherent"}},
+ {"id": "ownership", "label": {"zh": "各参考图没有串脸、串服装、串背景或复制无关物体", "en": "References do not leak identity, clothing, backgrounds, or unrelated objects"}},
+ {"id": "artifacts", "label": {"zh": "放大检查后无重影、畸形肢体、镜像文字、水印或多余物体", "en": "No ghosting, malformed limbs, mirrored text, watermark, or extra objects at full size"}},
+ ],
+}
+
+EDIT_MODEL_HINTS = (
+ "qwen-image-edit",
+ "flux.2-klein",
+ "flux2-klein",
+ "nano-banana",
+ "gpt-image",
+ "gemini-3-pro-image",
+ "gemini-3.1-flash-image",
+)
+STANDARD_PRIORITIES = ("gemini-3-pro-image-preview", "gpt-image-2-vip", "nano-banana-pro-4k-vip", "qwen-image-edit-2511")
+
+GARMENT_CATEGORY_ALIASES = {
+ "upper": "upper",
+ "upper_body": "upper",
+ "upper-body": "upper",
+ "top": "upper",
+ "tops": "upper",
+ "上装": "upper",
+ "上衣": "upper",
+ "lower": "lower",
+ "lower_body": "lower",
+ "lower-body": "lower",
+ "bottom": "lower",
+ "bottoms": "lower",
+ "下装": "lower",
+ "裤装": "lower",
+ "裙装": "lower",
+ "dress": "dress",
+ "one-piece": "dress",
+ "one_piece": "dress",
+ "连衣裙": "dress",
+ "连体衣": "dress",
+}
+
+
+def validate_operation(value: str) -> str:
+ operation = str(value or "").strip().lower()
+ if operation not in OPERATIONS:
+ raise ValueError("不支持的电商功能")
+ return operation
+
+
+def validate_mode(value: str) -> str:
+ mode = str(value or "").strip().lower()
+ if mode in LEGACY_MODES:
+ return "standard"
+ if mode not in MODES:
+ raise ValueError("生成模式只能是 standard")
+ return "standard"
+
+
+def normalize_garment_analysis(value: dict[str, Any] | None) -> dict[str, Any]:
+ data = value if isinstance(value, dict) else {}
+ raw_category = str(data.get("category") or data.get("garment_category") or "").strip().lower()
+ category = GARMENT_CATEGORY_ALIASES.get(raw_category, "auto")
+ garment_type = re.sub(r"\s+", " ", str(data.get("garment_type") or data.get("type") or "").strip())[:120]
+ reason = re.sub(r"\s+", " ", str(data.get("reason") or "").strip())[:240]
+ try:
+ confidence = max(0.0, min(1.0, float(data.get("confidence") or 0)))
+ except (TypeError, ValueError):
+ confidence = 0.0
+ return {
+ "category": category,
+ "garment_type": garment_type,
+ "confidence": round(confidence, 4),
+ "reason": reason,
+ }
+
+
+def parse_garment_analysis(text: str) -> dict[str, Any]:
+ value = str(text or "").strip()
+ value = re.sub(r"^```(?:json)?\s*", "", value, flags=re.IGNORECASE).strip()
+ value = re.sub(r"\s*```$", "", value).strip()
+ try:
+ data = json.loads(value)
+ except Exception:
+ match = re.search(r"\{.*?\}", value, re.S)
+ data = json.loads(match.group(0)) if match else {}
+ return normalize_garment_analysis(data)
+
+
+def normalize_universal_reference_analysis(value: dict[str, Any] | None) -> dict[str, Any]:
+ data = value if isinstance(value, dict) else {}
+ item_name = re.sub(r"\s+", " ", str(data.get("item_name") or data.get("name") or "").strip())[:120]
+ category = re.sub(r"\s+", " ", str(data.get("category") or data.get("type") or "").strip())[:80]
+ interaction = str(data.get("interaction") or "").strip().lower()
+ if interaction not in UNIVERSAL_INTERACTIONS:
+ interaction = ""
+ placement = re.sub(r"\s+", " ", str(data.get("placement") or "").strip())[:160]
+ visual_details = re.sub(r"\s+", " ", str(data.get("visual_details") or data.get("details") or "").strip())[:300]
+ reason = re.sub(r"\s+", " ", str(data.get("reason") or "").strip())[:240]
+ try:
+ confidence = max(0.0, min(1.0, float(data.get("confidence") or 0)))
+ except (TypeError, ValueError):
+ confidence = 0.0
+ return {
+ "item_name": item_name,
+ "category": category,
+ "interaction": interaction,
+ "placement": placement,
+ "visual_details": visual_details,
+ "confidence": round(confidence, 4),
+ "reason": reason,
+ }
+
+
+def parse_universal_reference_analysis(text: str) -> dict[str, Any]:
+ value = str(text or "").strip()
+ value = re.sub(r"^```(?:json)?\s*", "", value, flags=re.IGNORECASE).strip()
+ value = re.sub(r"\s*```$", "", value).strip()
+ try:
+ data = json.loads(value)
+ except Exception:
+ match = re.search(r"\{.*?\}", value, re.S)
+ data = json.loads(match.group(0)) if match else {}
+ return normalize_universal_reference_analysis(data)
+
+
+def is_compatible_edit_model(model: str) -> bool:
+ value = str(model or "").strip().lower()
+ return bool(value and any(hint in value for hint in EDIT_MODEL_HINTS))
+
+
+def build_model_catalog(providers: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
+ catalog: list[dict[str, Any]] = []
+ for provider_index, provider in enumerate(providers or []):
+ if not isinstance(provider, dict) or not provider.get("enabled", True):
+ continue
+ provider_id = str(provider.get("id") or "").strip().lower()
+ if not provider_id:
+ continue
+ for model_index, model in enumerate(provider.get("image_models") or []):
+ model_name = str(model or "").strip()
+ if not is_compatible_edit_model(model_name):
+ continue
+ low = model_name.lower()
+ if "gemini-3" in low or "nano-banana-pro" in low or "nano-banana-2" in low:
+ max_reference_images = 14
+ elif "gemini-2.5" in low or low in {"nano-banana", "nano-banana-fast"}:
+ max_reference_images = 3
+ else:
+ max_reference_images = 10
+ catalog.append({
+ "provider_id": provider_id,
+ "provider_name": str(provider.get("name") or provider_id),
+ "model": model_name,
+ "primary": bool(provider.get("primary")),
+ "provider_order": provider_index,
+ "model_order": model_index,
+ "supports_multi_reference": True,
+ "supports_mask": "gpt-image" in low or "qwen-image-edit" in low or "flux.2" in low,
+ "max_reference_images": max_reference_images,
+ })
+ return catalog
+
+
+def _priority_index(model: str, mode: str) -> int:
+ validate_mode(mode)
+ low = str(model or "").lower()
+ for index, hint in enumerate(STANDARD_PRIORITIES):
+ if hint in low:
+ return index
+ return len(STANDARD_PRIORITIES) + 1
+
+
+def route_candidates(
+ catalog: Iterable[dict[str, Any]],
+ mode: str,
+ provider_id: str = "",
+ model: str = "",
+) -> list[dict[str, Any]]:
+ mode = validate_mode(mode)
+ provider_id = str(provider_id or "").strip().lower()
+ model = str(model or "").strip()
+ items = [dict(item) for item in catalog or [] if isinstance(item, dict)]
+ if provider_id:
+ items = [item for item in items if str(item.get("provider_id") or "").lower() == provider_id]
+ if model:
+ exact = [item for item in items if str(item.get("model") or "") == model]
+ if not exact:
+ raise ValueError("所选平台没有该兼容图片编辑模型")
+ exact.sort(key=lambda item: (
+ 0 if item.get("primary") else 1,
+ int(item.get("provider_order") or 0),
+ int(item.get("model_order") or 0),
+ ))
+ return exact
+ items.sort(key=lambda item: (
+ _priority_index(item.get("model", ""), mode),
+ 0 if item.get("primary") else 1,
+ int(item.get("provider_order") or 0),
+ int(item.get("model_order") or 0),
+ ))
+ return items
+
+
+def select_route(catalog: Iterable[dict[str, Any]], mode: str, provider_id: str = "", model: str = "") -> dict[str, Any]:
+ candidates = route_candidates(catalog, mode, provider_id, model)
+ if not candidates:
+ raise ValueError("没有找到兼容的图片编辑模型,请检查 API 设置")
+ return candidates[0]
+
+
+def normalize_inputs(inputs: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
+ normalized: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for value in inputs or []:
+ if not isinstance(value, dict):
+ continue
+ role = str(value.get("role") or "").strip().lower()
+ url = str(value.get("url") or "").strip()
+ if role not in ALLOWED_INPUT_ROLES or not url or role in seen:
+ continue
+ seen.add(role)
+ normalized.append({
+ "role": role,
+ "url": url,
+ "name": str(value.get("name") or role)[:240],
+ "kind": "image",
+ "mime": str(value.get("mime") or "")[:120],
+ })
+ return normalized
+
+
+def normalize_universal_inputs(inputs: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
+ normalized: list[dict[str, Any]] = []
+ seen_ids: set[str] = set()
+ for index, value in enumerate(inputs or []):
+ if not isinstance(value, dict):
+ continue
+ reference_type = str(value.get("reference_type") or value.get("role") or "").strip().lower()
+ url = str(value.get("url") or "").strip()
+ if reference_type not in UNIVERSAL_REFERENCE_ROLE_IDS or not url:
+ continue
+ raw_id = re.sub(r"[^a-zA-Z0-9_-]", "", str(value.get("reference_id") or f"reference_{index + 1}"))[:80]
+ reference_id = raw_id or f"reference_{index + 1}"
+ if reference_id in seen_ids:
+ reference_id = f"{reference_id}_{index + 1}"
+ seen_ids.add(reference_id)
+ normalized.append({
+ "role": reference_type,
+ "reference_type": reference_type,
+ "reference_id": reference_id,
+ "url": url,
+ "name": str(value.get("name") or reference_type)[:240],
+ "label": re.sub(r"\s+", " ", str(value.get("label") or "").strip())[:160],
+ "instruction": re.sub(r"\s+", " ", str(value.get("instruction") or "").strip())[:300],
+ "kind": "image",
+ "mime": str(value.get("mime") or "")[:120],
+ })
+ if len(normalized) >= UNIVERSAL_REFERENCE_LIMIT:
+ break
+ return normalized
+
+
+def validate_input_roles(operation: str, inputs: Iterable[dict[str, Any]], options: dict[str, Any] | None = None) -> list[dict[str, Any]]:
+ operation = validate_operation(operation)
+ options = options if isinstance(options, dict) else {}
+ if operation == "universal":
+ values = list(inputs or [])
+ if len(values) > UNIVERSAL_REFERENCE_LIMIT:
+ raise ValueError(f"全能模式最多上传 {UNIVERSAL_REFERENCE_LIMIT} 张参考图")
+ normalized = normalize_universal_inputs(values)
+ if not any(item["reference_type"] == "subject" for item in normalized):
+ raise ValueError("全能模式至少需要一张主体/模特参考图")
+ return normalized
+ normalized = normalize_inputs(inputs)
+ roles = {item["role"] for item in normalized}
+ required = set(OPERATION_INPUTS[operation])
+ if operation == "pose_transfer" and str(options.get("pose_source") or "preset") == "reference":
+ required.add("pose")
+ if operation == "background_change" and str(options.get("background_mode") or "preset") == "reference":
+ required.add("background")
+ missing = sorted(required - roles)
+ if missing:
+ raise ValueError("缺少必需输入:" + "、".join(missing))
+ return normalized
+
+
+def target_size(width: int, height: int, mode: str, aspect_ratio: str = "source", resolution: str = "auto") -> str:
+ mode = validate_mode(mode)
+ width = max(1, int(width or 1))
+ height = max(1, int(height or 1))
+ aspect_ratio = str(aspect_ratio or "source").strip().lower()
+ resolution = str(resolution or "auto").strip().lower()
+ if aspect_ratio not in ASPECT_RATIOS:
+ raise ValueError("不支持的生成比例")
+ if resolution not in RESOLUTIONS:
+ raise ValueError("分辨率只能是 auto、1k、2k 或 4k")
+ resolved_resolution = "2k" if resolution == "auto" else resolution
+ if aspect_ratio != "source":
+ return SIZE_PRESETS[aspect_ratio][resolved_resolution]
+ long_edge = {"1k": 1024, "2k": 2048, "4k": 3840}[resolved_resolution]
+ scale = long_edge / max(width, height)
+ out_w = max(64, int(round(width * scale / 64)) * 64)
+ out_h = max(64, int(round(height * scale / 64)) * 64)
+ return f"{out_w}x{out_h}"
+
+
+def resolve_generation_settings(
+ width: int,
+ height: int,
+ mode: str,
+ aspect_ratio: str = "source",
+ resolution: str = "auto",
+ quality: str = "auto",
+ count: int = 0,
+) -> dict[str, Any]:
+ mode = validate_mode(mode)
+ aspect_ratio = str(aspect_ratio or "source").strip().lower()
+ resolution = str(resolution or "auto").strip().lower()
+ quality = str(quality or "auto").strip().lower()
+ if aspect_ratio not in ASPECT_RATIOS:
+ raise ValueError("不支持的生成比例")
+ if resolution not in RESOLUTIONS:
+ raise ValueError("分辨率只能是 auto、1k、2k 或 4k")
+ if quality not in QUALITIES:
+ raise ValueError("质量只能是 auto、low、medium 或 high")
+ try:
+ selected_count = int(count or 0)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("生成数量必须是 1 到 4") from exc
+ if selected_count < 0 or selected_count > 4:
+ raise ValueError("生成数量必须是 1 到 4,或使用自动")
+ resolved_resolution = "2k" if resolution == "auto" else resolution
+ resolved_quality = "high" if quality == "auto" else quality
+ resolved_count = 1 if selected_count == 0 else selected_count
+ return {
+ "parameters": {
+ "aspect_ratio": aspect_ratio,
+ "resolution": resolution,
+ "quality": quality,
+ "count": selected_count,
+ },
+ "aspect_ratio": aspect_ratio,
+ "resolution": resolved_resolution,
+ "size": target_size(width, height, mode, aspect_ratio, resolved_resolution),
+ "quality": resolved_quality,
+ "count": resolved_count,
+ }
+
+
+def _preset_prompt(items: list[dict[str, Any]], preset_id: str, default_id: str) -> str:
+ selected = next((item for item in items if item.get("id") == preset_id), None)
+ if not selected:
+ selected = next((item for item in items if item.get("id") == default_id), items[0])
+ return str(selected.get("prompt") or "")
+
+
+def _global_preservation() -> str:
+ return (
+ "Change only the requested dimension. Preserve identity, silhouette, proportions, colors, materials, "
+ "patterns, logos, readable product text, approved accessories, lighting, camera, and every non-target region. "
+ "Do not add people, products, text, watermarks, duplicated limbs, mirrored logos, or unrelated objects."
+ )
+
+
+def _clean_prompt_text(*values: Any) -> str:
+ return re.sub(r"\s+", " ", " ".join(str(value or "") for value in values).strip()).lower()
+
+
+def _analysis_lookup(options: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
+ raw = (options or {}).get("reference_analysis")
+ if isinstance(raw, dict):
+ items = raw.items()
+ elif isinstance(raw, list):
+ items = ((str(item.get("reference_id") or item.get("id") or index), item) for index, item in enumerate(raw) if isinstance(item, dict))
+ else:
+ items = []
+ normalized: dict[str, dict[str, Any]] = {}
+ for key, value in items:
+ if isinstance(value, dict):
+ normalized[str(key)] = normalize_universal_reference_analysis(value)
+ return normalized
+
+
+def _reference_analysis(item: dict[str, Any], options: dict[str, Any] | None) -> dict[str, Any]:
+ analysis = _analysis_lookup(options)
+ return analysis.get(str(item.get("reference_id") or "")) or analysis.get(str(item.get("name") or "")) or {}
+
+
+def _reference_detail(item: dict[str, Any], analysis: dict[str, Any] | None = None) -> str:
+ analysis = analysis or {}
+ detail = (
+ analysis.get("item_name")
+ or item.get("label")
+ or analysis.get("category")
+ or item.get("name")
+ or item.get("reference_type")
+ or item.get("role")
+ or "reference"
+ )
+ visual = analysis.get("visual_details") or ""
+ return re.sub(r"\s+", " ", f"{detail}; {visual}" if visual else str(detail)).strip()[:360]
+
+
+def infer_universal_interaction(item: dict[str, Any], analysis: dict[str, Any] | None = None) -> str:
+ analysis = analysis or {}
+ role = str(item.get("reference_type") or item.get("role") or "").strip().lower()
+ suggested = str(analysis.get("interaction") or "").strip().lower()
+ text = _clean_prompt_text(
+ role,
+ item.get("label"),
+ item.get("instruction"),
+ item.get("name"),
+ analysis.get("item_name"),
+ analysis.get("category"),
+ analysis.get("visual_details"),
+ )
+ if role == "subject":
+ return "identity"
+ if role in {"upper_garment", "lower_garment", "full_garment"}:
+ return "wear"
+ if role == "shoes":
+ return "put_on"
+ if role == "pose":
+ return "pose"
+ if role == "scene":
+ return "scene"
+ if role == "style":
+ return "style"
+ if any(keyword in text for keyword in BAG_KEYWORDS):
+ return "carry"
+ if any(keyword in text for keyword in PLACED_PROP_KEYWORDS):
+ return "place"
+ if any(keyword in text for keyword in HANDHELD_KEYWORDS):
+ return "hold"
+ if any(keyword in text for keyword in ACCESSORY_WEAR_KEYWORDS):
+ return "wear"
+ if suggested in {"wear", "put_on", "hold", "carry", "place", "use"}:
+ return suggested
+ return "wear" if role == "accessory" else "hold"
+
+
+def _interaction_phrase(index: int, item: dict[str, Any], analysis: dict[str, Any] | None = None) -> str:
+ role = str(item.get("reference_type") or item.get("role") or "").strip().lower()
+ detail = _reference_detail(item, analysis)
+ interaction = infer_universal_interaction(item, analysis)
+ if role == "subject":
+ return f"Use Image {index} as the exact same primary model, preserving identity, face, hair, body proportions, and skin tone."
+ if role == "upper_garment":
+ return f"Dress the model in the exact upper garment from Image {index} ({detail})."
+ if role == "lower_garment":
+ return f"Dress the model in the exact lower garment from Image {index} ({detail})."
+ if role == "full_garment":
+ return f"Dress the model in the exact full outfit or dress from Image {index} ({detail})."
+ if role == "shoes":
+ return f"Put the exact shoes from Image {index} ({detail}) on the model's feet."
+ if role in {"accessory", "prop"}:
+ if interaction == "wear":
+ verb = "Have the model wear"
+ elif interaction == "put_on":
+ verb = "Put"
+ elif interaction == "carry":
+ verb = "Have the model naturally carry"
+ elif interaction == "place":
+ verb = "Place"
+ elif interaction == "use":
+ verb = "Have the model naturally use"
+ else:
+ verb = "Have the model naturally hold"
+ placement = f" {analysis.get('placement')}." if analysis and analysis.get("placement") else ""
+ return f"{verb} the exact item from Image {index} ({detail}).{placement}"
+ if role == "pose":
+ return f"Make the model follow only the body pose/action from Image {index}; do not copy that person's identity, clothing, accessories, or background."
+ if role == "scene":
+ return f"Place the model and products inside the scene from Image {index}, matching environment layout, perspective, and natural lighting."
+ if role == "style":
+ return f"Apply only the color, lighting, contrast, and finish style from Image {index}; do not copy its subjects or layout."
+ return f"Use Image {index} as a reference for {detail}."
+
+
+def build_universal_auto_instruction(inputs: Iterable[dict[str, Any]], options: dict[str, Any] | None = None) -> str:
+ normalized = list(inputs or [])
+ lines = []
+ for index, item in enumerate(normalized, 1):
+ lines.append(_interaction_phrase(index, item, _reference_analysis(item, options)))
+ if not lines:
+ return ""
+ return (
+ "AUTO FINAL COMPOSITION: "
+ + " ".join(lines)
+ + " Produce one coherent, high-end e-commerce product image with a polished catalog/lifestyle look, clean composition, believable fit, contact, scale, shadows, and product fidelity."
+ )
+
+
+def build_prompt(operation: str, inputs: Iterable[dict[str, Any]], options: dict[str, Any] | None = None) -> str:
+ operation = validate_operation(operation)
+ normalized = validate_input_roles(operation, inputs, options)
+ options = options if isinstance(options, dict) else {}
+ ordered_roles = [item["role"] for item in normalized if item["role"] != "mask"]
+ role_lines = "; ".join(f"Image {index + 1} is {role}" for index, role in enumerate(ordered_roles))
+ mask_note = " A final mask reference marks red pixels to replace and green pixels to preserve." if any(item["role"] == "mask" for item in normalized) else ""
+ instruction = str(options.get("instruction") or "").strip()
+
+ if operation == "universal":
+ role_names = {
+ "subject": "PRIMARY SUBJECT / MODEL IDENTITY",
+ "upper_garment": "UPPER GARMENT",
+ "lower_garment": "LOWER GARMENT",
+ "full_garment": "DRESS OR FULL OUTFIT",
+ "shoes": "SHOES",
+ "accessory": "JEWELRY OR ACCESSORY",
+ "prop": "PROP OR PRODUCT",
+ "pose": "POSE ONLY",
+ "scene": "SCENE / BACKGROUND ONLY",
+ "style": "STYLE / LIGHTING ONLY",
+ }
+ reference_map = []
+ for index, item in enumerate(normalized):
+ analysis = _reference_analysis(item, options)
+ detail = item.get("label") or item.get("name") or item["reference_type"]
+ if analysis.get("item_name"):
+ detail = analysis["item_name"]
+ if analysis.get("visual_details"):
+ detail = f"{detail}; {analysis['visual_details']}"
+ note = f"; specific instruction: {item['instruction']}" if item.get("instruction") else ""
+ reference_map.append(f"Image {index + 1} = [{role_names[item['reference_type']]}] {detail}{note}")
+ auto_instruction = build_universal_auto_instruction(normalized, options)
+ user_instruction = instruction
+ final_instruction = auto_instruction
+ if user_instruction:
+ final_instruction = f"{auto_instruction}\nUSER SUPPLEMENT: {user_instruction}" if auto_instruction else user_instruction
+ task = (
+ "Create one coherent, photorealistic e-commerce image by following this exact ordered reference map:\n"
+ + "\n".join(reference_map)
+ + "\nFINAL COMPOSITION: " + final_instruction
+ + "\nREFERENCE OWNERSHIP RULES: Subject references own identity, face, hair, skin tone, and body proportions only. "
+ "Garment, shoe, accessory, and prop references own their exact product geometry, construction, material, color, pattern, logo, and readable text only. "
+ "Pose references own only body posture, joint arrangement, balance, and gesture; never copy their identity, clothing, accessories, camera, or background. "
+ "Scene references own only environment, layout, camera perspective, and environmental lighting; never copy foreground people or products. "
+ "Style references own only palette, finish, contrast, and lighting treatment; never copy subjects or layout. "
+ "CONFLICT PRIORITY: (1) subject identity, (2) exact product fidelity, (3) requested pose and contact, (4) scene composition and lighting, (5) style. "
+ "Resolve occlusion, fit, scale, perspective, grip, contact shadows, reflections, fabric folds, and anatomy physically. "
+ "Do not blend identities or leak clothing, people, props, or backgrounds between references."
+ )
+ elif operation == "try_on":
+ category = {"upper": "upper-body garment", "lower": "lower-body garment", "dress": "dress or one-piece", "auto": "garment"}.get(str(options.get("garment_category") or "auto"), "garment")
+ garment_type = re.sub(r"\s+", " ", str(options.get("garment_type") or "").strip())[:120]
+ detected_note = f" The garment was visually identified as {garment_type}." if garment_type else ""
+ task = (
+ f"Put the exact {category} from the garment reference onto the person in the source image. "
+ f"{detected_note} "
+ "Preserve the source person's face, hair, body shape, pose, hands, framing, lighting, and background. "
+ "Preserve the garment neckline, sleeve and hem geometry, fit, fabric texture, colors, pattern, logo, and text. "
+ "Create physically natural folds, seams, coverage, and occlusions."
+ )
+ elif operation == "pose_transfer":
+ if str(options.get("pose_source") or "preset") == "reference":
+ target = "Use only the body posture and joint arrangement from the pose reference image. Do not copy that person's identity, clothes, or background."
+ else:
+ target = "Apply this target pose: " + _preset_prompt(POSE_PRESETS, str(options.get("pose_preset") or "standing_front"), "standing_front") + "."
+ task = (
+ target + " Preserve the source person's identity, facial expression, body proportions, outfit, accessories, product details, camera framing, lighting, and background. "
+ "Keep anatomy, balance, hands, feet, folds, and occlusions realistic."
+ )
+ elif operation == "prop_replace":
+ target_description = str(options.get("target_description") or "the matching existing prop").strip()
+ task = (
+ f"Replace only {target_description} in the source image with the exact prop from the prop reference. "
+ "Match believable scale, perspective, grip or contact, lighting, shadow, and reflections. Preserve the new prop's shape, material, colors, logo, and text. "
+ "Remove every remnant of the old prop while leaving all pixels outside the target region semantically unchanged."
+ )
+ elif operation == "angle_change":
+ azimuth = max(-180, min(180, int(options.get("azimuth") or 0)))
+ elevation = max(-30, min(30, int(options.get("elevation") or 0)))
+ distance = {"close": "close shot", "medium": "medium shot", "wide": "wide full-subject shot"}.get(str(options.get("distance") or "medium"), "medium shot")
+ task = (
+ f"Move the camera to azimuth {azimuth} degrees and elevation {elevation} degrees, using a {distance}. "
+ "Rotate the viewpoint around the subject; do not rotate, redesign, mirror, or replace the subject. "
+ "Infer newly visible surfaces consistently with the same structure, materials, colors, logos, and text."
+ )
+ else:
+ background_mode = str(options.get("background_mode") or "preset")
+ if background_mode == "reference":
+ target = "Use the background reference for environment, composition, palette, and lighting, without copying any foreground subject from it."
+ elif background_mode == "prompt":
+ target = str(options.get("background_prompt") or "clean professional e-commerce studio background").strip()
+ else:
+ target = _preset_prompt(BACKGROUND_PRESETS, str(options.get("background_preset") or "studio_white"), "studio_white")
+ task = (
+ f"Replace only the background with: {target} Preserve the foreground person or product exactly, including silhouette, hair, transparent materials, colors, logos, and text. "
+ "Create natural contact shadows, reflections, depth of field, and coherent light direction without halos or clipped edges."
+ )
+
+ preservation = (
+ "Preserve every reference-owned attribute unless the final composition explicitly changes it. "
+ "Add only the mapped subjects and products. Do not add unrelated people, products, text, watermarks, duplicate objects, or extra limbs."
+ if operation == "universal" else _global_preservation() + mask_note
+ )
+ parts = ([] if operation == "universal" else [role_lines + "."]) + [task, preservation]
+ if instruction and operation != "universal":
+ parts.append("Additional user instruction: " + instruction)
+ return " ".join(part for part in parts if part).strip()
+
+
+def safe_fallback_error(status_code: int, detail: str) -> bool:
+ status_code = int(status_code or 0)
+ if status_code == 405:
+ return True
+ if status_code not in {400, 404, 422}:
+ return False
+ text = str(detail or "").lower()
+ markers = (
+ "not support", "unsupported", "does not support", "model not found", "model does not exist",
+ "no such model", "images api is not supported", "不支持", "未找到模型", "模型不存在",
+ )
+ return any(marker in text for marker in markers)
+
+
+def public_capabilities(providers: Iterable[dict[str, Any]]) -> dict[str, Any]:
+ catalog = build_model_catalog(providers)
+ routes: dict[str, Any] = {}
+ candidates = route_candidates(catalog, "standard")
+ routes["standard"] = candidates[0] if candidates else None
+ provider_items: list[dict[str, str]] = []
+ seen: set[str] = set()
+ for item in catalog:
+ if item["provider_id"] in seen:
+ continue
+ seen.add(item["provider_id"])
+ provider_items.append({"id": item["provider_id"], "name": item["provider_name"]})
+ public_models = [{key: value for key, value in item.items() if key not in {"provider_order", "model_order", "primary"}} for item in catalog]
+ public_routes = {
+ mode: ({key: value for key, value in route.items() if key not in {"provider_order", "model_order", "primary"}} if route else None)
+ for mode, route in routes.items()
+ }
+ return {
+ "operations": list(OPERATIONS),
+ "modes": list(MODES),
+ "providers": provider_items,
+ "models": public_models,
+ "routes": public_routes,
+ "pose_presets": POSE_PRESETS,
+ "background_presets": BACKGROUND_PRESETS,
+ "quality_checks": QUALITY_CHECKS,
+ "universal_reference_roles": UNIVERSAL_REFERENCE_ROLES,
+ "universal_reference_limit": UNIVERSAL_REFERENCE_LIMIT,
+ "defaults": {
+ "standard": {"count": 1, "resolution": "2k", "quality": "high", "aspect_ratio": "source"},
+ },
+ }
diff --git a/canvas_core/events.py b/canvas_core/events.py
new file mode 100644
index 000000000..b2eb26d4f
--- /dev/null
+++ b/canvas_core/events.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+import time
+from dataclasses import asdict, dataclass
+
+
+EVENT_TOPICS = {
+ "canvas",
+ "project",
+ "asset",
+ "prompt",
+ "platform",
+ "workflow",
+ "preference",
+ "history",
+ "session",
+ "task",
+}
+
+
+@dataclass(frozen=True)
+class ChangeEvent:
+ type: str
+ topic: str
+ entity_id: str
+ revision: int
+ actor_id: str
+ updated_at: int
+
+ def public(self) -> dict[str, object]:
+ return asdict(self)
+
+
+def entity_changed(
+ topic: str,
+ entity_id: str = "global",
+ revision: int = 0,
+ actor_id: str = "",
+ updated_at: int = 0,
+) -> ChangeEvent:
+ normalized = str(topic or "").strip().lower()
+ if normalized not in EVENT_TOPICS:
+ raise ValueError(f"不支持的事件主题:{normalized or '(empty)'}")
+ return ChangeEvent(
+ type="entity.changed",
+ topic=normalized,
+ entity_id=str(entity_id or "global"),
+ revision=max(0, int(revision or 0)),
+ actor_id=str(actor_id or ""),
+ updated_at=int(updated_at or time.time() * 1000),
+ )
diff --git a/canvas_core/maintenance.py b/canvas_core/maintenance.py
new file mode 100644
index 000000000..091ba6b41
--- /dev/null
+++ b/canvas_core/maintenance.py
@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+import json
+import os
+import threading
+import time
+from pathlib import Path
+from typing import Any
+
+from .data_layout import DataLayout
+
+
+GIB = 1024 * 1024 * 1024
+DEFAULT_CACHE_LIMIT = 10 * GIB
+HARD_CACHE_LIMIT = 20 * GIB
+DEFAULT_TEMP_MAX_AGE = 24 * 60 * 60
+DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024
+
+
+class MaintenanceManager:
+ """Keep disposable data bounded without touching user media or database files."""
+
+ def __init__(self, layout: DataLayout, interval_seconds: int = 60 * 60):
+ self.layout = layout
+ self.interval_seconds = max(60, int(interval_seconds))
+ self._started = False
+ self._lock = threading.Lock()
+
+ def _cache_limit(self) -> int:
+ value = DEFAULT_CACHE_LIMIT
+ try:
+ payload = json.loads(self.layout.app_config.read_text(encoding="utf-8"))
+ value = int(payload.get("cache_max_bytes", value))
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
+ pass
+ return max(0, min(value, HARD_CACHE_LIMIT))
+
+ @staticmethod
+ def _files(root: Path) -> list[Path]:
+ if not root.exists():
+ return []
+ return [path for path in root.rglob("*") if path.is_file() and not path.is_symlink()]
+
+ def _trim_cache(self) -> dict[str, int]:
+ entries: list[tuple[float, int, Path]] = []
+ for path in self._files(self.layout.cache):
+ try:
+ stat = path.stat()
+ entries.append((stat.st_atime or stat.st_mtime, stat.st_size, path))
+ except OSError:
+ continue
+ total = sum(item[1] for item in entries)
+ removed = 0
+ removed_bytes = 0
+ limit = self._cache_limit()
+ for _accessed_at, size, path in sorted(entries, key=lambda item: item[0]):
+ if total <= limit:
+ break
+ try:
+ path.unlink()
+ total -= size
+ removed += 1
+ removed_bytes += size
+ except OSError:
+ continue
+ return {"limit": limit, "remaining_bytes": total, "removed": removed, "removed_bytes": removed_bytes}
+
+ def _clean_temp(self, max_age_seconds: int = DEFAULT_TEMP_MAX_AGE) -> int:
+ cutoff = time.time() - max_age_seconds
+ removed = 0
+ for path in self._files(self.layout.temp):
+ try:
+ if path.stat().st_mtime < cutoff:
+ path.unlink()
+ removed += 1
+ except OSError:
+ continue
+ for directory in sorted((path for path in self.layout.temp.rglob("*") if path.is_dir()), reverse=True):
+ try:
+ directory.rmdir()
+ except OSError:
+ pass
+ return removed
+
+ def _rotate_logs(self, max_bytes: int = DEFAULT_LOG_MAX_BYTES, backups: int = 5) -> int:
+ rotated = 0
+ for path in self.layout.logs.glob("*.log"):
+ try:
+ if path.stat().st_size <= max_bytes:
+ continue
+ oldest = path.with_name(f"{path.name}.{backups}")
+ if oldest.exists():
+ oldest.unlink()
+ for number in range(backups - 1, 0, -1):
+ source = path.with_name(f"{path.name}.{number}")
+ if source.exists():
+ os.replace(source, path.with_name(f"{path.name}.{number + 1}"))
+ os.replace(path, path.with_name(f"{path.name}.1"))
+ rotated += 1
+ except OSError:
+ continue
+ return rotated
+
+ def run_once(self) -> dict[str, Any]:
+ with self._lock:
+ return {
+ "cache": self._trim_cache(),
+ "temp_removed": self._clean_temp(),
+ "logs_rotated": self._rotate_logs(),
+ "completed_at": int(time.time() * 1000),
+ }
+
+ def start(self) -> None:
+ if self._started:
+ return
+ self._started = True
+
+ def loop() -> None:
+ while True:
+ time.sleep(self.interval_seconds)
+ self.run_once()
+
+ threading.Thread(target=loop, name="canvas-data-maintenance", daemon=True).start()
diff --git a/canvas_core/migration.py b/canvas_core/migration.py
new file mode 100644
index 000000000..04ffcfd5a
--- /dev/null
+++ b/canvas_core/migration.py
@@ -0,0 +1,246 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+import time
+from pathlib import Path
+from typing import Any, Iterable
+
+from .data_layout import DataLayout, atomic_write_json
+from .database import CanvasDatabase
+from .paths import AppPaths
+
+
+def _read_json(path: Path, default: Any) -> Any:
+ if not path.is_file():
+ return default
+ try:
+ with path.open("r", encoding="utf-8-sig") as handle:
+ return json.load(handle)
+ except (OSError, json.JSONDecodeError):
+ return default
+
+
+def _file_hash(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+class LegacyMigrator:
+ VERSION = 1
+
+ def __init__(self, paths: AppPaths, layout: DataLayout, database: CanvasDatabase):
+ self.paths = paths
+ self.layout = layout
+ self.database = database
+ self.manifest: dict[str, Any] = {}
+
+ def run(self) -> dict[str, Any]:
+ self.layout.ensure()
+ existing = self.layout.manifest_payload()
+ migration = existing.get("migration") if isinstance(existing.get("migration"), dict) else {}
+ if int(existing.get("schema_version") or 0) >= self.VERSION and migration.get("status") == "complete":
+ return migration
+ if migration.get("status") == "running":
+ self._rollback(migration.get("moves") or [])
+
+ stamp = time.strftime("%Y%m%d-%H%M%S")
+ backup_dir = self.layout.backups / f"migration-v{self.VERSION}-{stamp}"
+ backup_dir.mkdir(parents=True, exist_ok=False)
+ self.manifest = {
+ "schema_version": 0,
+ "migration": {
+ "version": self.VERSION,
+ "status": "running",
+ "started_at": int(time.time() * 1000),
+ "backup_dir": str(backup_dir),
+ "moves": [],
+ },
+ }
+ self._save_manifest()
+
+ try:
+ snapshot, structured_sources = self._snapshot_legacy()
+ self.database.import_legacy(snapshot)
+ self._migrate_media()
+ self._migrate_custom_workflows()
+ self._migrate_secret_env(backup_dir)
+ self._archive_structured(structured_sources, backup_dir)
+ self._migrate_logs()
+ counts = self.database.counts()
+ self.manifest["schema_version"] = self.VERSION
+ self.manifest["migration"].update(
+ {
+ "status": "complete",
+ "completed_at": int(time.time() * 1000),
+ "database_counts": counts,
+ "media_files": self._count_files(self.layout.media),
+ }
+ )
+ self._save_manifest()
+ atomic_write_json(backup_dir / "migration-report.json", self.manifest["migration"])
+ return self.manifest["migration"]
+ except Exception as exc:
+ self.manifest["migration"]["status"] = "failed"
+ self.manifest["migration"]["error"] = str(exc)
+ self._save_manifest()
+ self._rollback(self.manifest["migration"].get("moves") or [])
+ raise
+
+ def _save_manifest(self) -> None:
+ atomic_write_json(self.layout.manifest, self.manifest)
+
+ def _record_move(self, source: Path, destination: Path) -> None:
+ operation = {"source": str(source), "destination": str(destination)}
+ self.manifest["migration"].setdefault("moves", []).append(operation)
+ self._save_manifest()
+
+ def _move_file(self, source: Path, destination: Path) -> Path:
+ if not source.is_file():
+ return destination
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ selected = destination
+ if selected.exists():
+ if selected.is_file() and source.stat().st_size == selected.stat().st_size and _file_hash(source) == _file_hash(selected):
+ selected = destination.with_name(f"{destination.stem}.legacy-duplicate{destination.suffix}")
+ else:
+ counter = 1
+ while selected.exists():
+ selected = destination.with_name(f"{destination.stem}.legacy-{counter}{destination.suffix}")
+ counter += 1
+ os.replace(source, selected)
+ self._record_move(source, selected)
+ return selected
+
+ def _snapshot_legacy(self) -> tuple[dict[str, Any], list[Path]]:
+ root = self.paths.portable_root
+ data = self.layout.root
+ structured: list[Path] = []
+
+ def tracked_json(path: Path, default: Any) -> Any:
+ if path.is_file():
+ structured.append(path)
+ return _read_json(path, default)
+
+ providers = tracked_json(data / "api_providers.json", [])
+ projects_raw = tracked_json(data / "projects.json", {})
+ projects = projects_raw.get("projects") if isinstance(projects_raw, dict) else projects_raw
+ history = tracked_json(root / "history.json", [])
+ if not history:
+ history = tracked_json(data / "history.json", [])
+
+ canvases = []
+ canvas_dir = data / "canvases"
+ if canvas_dir.is_dir():
+ for path in sorted(canvas_dir.glob("*.json")):
+ value = _read_json(path, None)
+ if isinstance(value, dict):
+ canvases.append(value)
+ structured.append(path)
+
+ conversations: list[tuple[str, dict[str, Any]]] = []
+ conversation_dir = data / "conversations"
+ if conversation_dir.is_dir():
+ for path in sorted(conversation_dir.glob("*/*.json")):
+ value = _read_json(path, None)
+ if isinstance(value, dict):
+ conversations.append((path.parent.name, value))
+ structured.append(path)
+
+ tasks_raw = tracked_json(data / "online_image_tasks.json", {})
+ tasks = tasks_raw.get("tasks") if isinstance(tasks_raw, dict) else tasks_raw
+ libraries = {
+ "asset_library": tracked_json(data / "asset_library.json", None),
+ "prompt_libraries": tracked_json(data / "prompt_libraries.json", None),
+ "shared_folders": tracked_json(data / "shared_folders.json", None),
+ "runninghub_workflows": tracked_json(data / "runninghub_workflows.json", None),
+ "legacy_global_config": tracked_json(root / "global_config.json", None),
+ }
+ return (
+ {
+ "providers": providers if isinstance(providers, list) else [],
+ "projects": projects if isinstance(projects, list) else [],
+ "canvases": canvases,
+ "conversations": conversations,
+ "history": history if isinstance(history, list) else [],
+ "online_image_tasks": tasks if isinstance(tasks, list) else [],
+ "libraries": libraries,
+ },
+ list(dict.fromkeys(structured)),
+ )
+
+ def _migrate_tree(self, source_root: Path, destination_root: Path) -> None:
+ if not source_root.is_dir() or source_root.resolve() == destination_root.resolve():
+ return
+ for source in sorted(source_root.rglob("*")):
+ if source.is_file():
+ self._move_file(source, destination_root / source.relative_to(source_root))
+
+ def _migrate_media(self) -> None:
+ root = self.paths.portable_root
+ legacy_assets = root / "assets"
+ self._migrate_tree(legacy_assets / "input", self.layout.media_input)
+ self._migrate_tree(legacy_assets / "output", self.layout.media_generated)
+ self._migrate_tree(legacy_assets / "library", self.layout.media_library)
+ self._migrate_tree(legacy_assets / "uploads", self.layout.media_uploads)
+ self._migrate_tree(root / "output", self.layout.exports)
+
+ def _migrate_custom_workflows(self) -> None:
+ workflow_root = self.paths.app_root / "workflows"
+ self._migrate_tree(workflow_root / "custom", self.layout.workflow_custom)
+ self._migrate_tree(workflow_root / "自定义", self.layout.workflow_custom)
+
+ def _migrate_secret_env(self, backup_dir: Path) -> None:
+ legacy_env = self.paths.portable_root / "API" / ".env"
+ if not legacy_env.is_file():
+ return
+ backup_env = backup_dir / "legacy-root" / "API" / ".env"
+ backup_env.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(legacy_env, backup_env)
+ self._move_file(legacy_env, self.layout.secret_env)
+
+ def _archive_structured(self, paths: Iterable[Path], backup_dir: Path) -> None:
+ root = self.paths.portable_root.resolve()
+ data_root = self.layout.root.resolve()
+ for source in paths:
+ if not source.is_file():
+ continue
+ resolved = source.resolve()
+ try:
+ relative = resolved.relative_to(data_root)
+ destination = backup_dir / "legacy-data" / relative
+ except ValueError:
+ try:
+ relative = resolved.relative_to(root)
+ except ValueError:
+ relative = Path(source.name)
+ destination = backup_dir / "legacy-root" / relative
+ self._move_file(source, destination)
+
+ def _migrate_logs(self) -> None:
+ for source in sorted(self.layout.root.glob("*.log")):
+ self._move_file(source, self.layout.logs / source.name)
+ for source in sorted(self.layout.root.glob("*.pid")):
+ self._move_file(source, self.layout.run / source.name)
+
+ def _rollback(self, moves: Iterable[dict[str, str]]) -> None:
+ for operation in reversed(list(moves)):
+ source = Path(str(operation.get("source") or ""))
+ destination = Path(str(operation.get("destination") or ""))
+ if not destination.is_file() or source.exists():
+ continue
+ source.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ os.replace(destination, source)
+ except OSError:
+ pass
+
+ @staticmethod
+ def _count_files(root: Path) -> int:
+ return sum(1 for path in root.rglob("*") if path.is_file()) if root.is_dir() else 0
+
diff --git a/canvas_core/paths.py b/canvas_core/paths.py
new file mode 100644
index 000000000..aec9dc008
--- /dev/null
+++ b/canvas_core/paths.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+import os
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Mapping, Optional
+
+
+def _resolved(value: str | os.PathLike[str]) -> Path:
+ return Path(value).expanduser().resolve()
+
+
+@dataclass(frozen=True)
+class AppPaths:
+ """Resolved read-only application roots and the single writable data root."""
+
+ portable_root: Path
+ app_root: Path
+ data_root: Path
+ web_root: Path
+ builtin_workflow_root: Path
+
+ @classmethod
+ def discover(
+ cls,
+ environ: Optional[Mapping[str, str]] = None,
+ module_file: Optional[str | os.PathLike[str]] = None,
+ executable: Optional[str | os.PathLike[str]] = None,
+ frozen: Optional[bool] = None,
+ ) -> "AppPaths":
+ env = os.environ if environ is None else environ
+ module_path = _resolved(module_file or __file__)
+ source_root = module_path.parent.parent
+ is_frozen = bool(getattr(sys, "frozen", False)) if frozen is None else frozen
+ executable_path = _resolved(executable or sys.executable)
+
+ configured_app_root = str(env.get("CANVAS_APP_ROOT") or "").strip()
+ if configured_app_root:
+ app_root = _resolved(configured_app_root)
+ elif is_frozen:
+ executable_dir = executable_path.parent
+ app_root = executable_dir.parent if executable_dir.name.lower() == "backend" else executable_dir
+ else:
+ app_root = source_root
+
+ configured_portable_root = str(env.get("CANVAS_PORTABLE_ROOT") or "").strip()
+ if configured_portable_root:
+ portable_root = _resolved(configured_portable_root)
+ elif app_root.name.lower() == "app":
+ portable_root = app_root.parent
+ else:
+ portable_root = app_root
+
+ configured_data_root = str(env.get("CANVAS_DATA_DIR") or "").strip()
+ data_root = _resolved(configured_data_root) if configured_data_root else portable_root / "data"
+
+ packaged_web_root = app_root / "web"
+ web_root = packaged_web_root if packaged_web_root.is_dir() else app_root / "static"
+ builtin_workflow_root = app_root / "workflows"
+
+ return cls(
+ portable_root=portable_root,
+ app_root=app_root,
+ data_root=data_root,
+ web_root=web_root,
+ builtin_workflow_root=builtin_workflow_root,
+ )
+
+ @property
+ def version_file(self) -> Path:
+ portable_version = self.portable_root / "VERSION"
+ return portable_version if portable_version.is_file() else self.app_root / "VERSION"
+
+ def public_summary(self) -> dict[str, str]:
+ return {
+ "portable_root": str(self.portable_root),
+ "app_root": str(self.app_root),
+ "data_root": str(self.data_root),
+ "web_root": str(self.web_root),
+ "workflow_root": str(self.builtin_workflow_root),
+ }
+
+
+APP_PATHS = AppPaths.discover()
+
diff --git a/canvas_core/runtime.py b/canvas_core/runtime.py
new file mode 100644
index 000000000..fa06ccddc
--- /dev/null
+++ b/canvas_core/runtime.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+import json
+import threading
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterable, Optional
+
+
+@dataclass(frozen=True)
+class RuntimeOptions:
+ host: str
+ port: int
+ data_dir: str
+ app_root: str
+ portable_root: str
+ desktop_token: str
+ parent_pid: int
+ mode: str
+
+
+def bootstrap_runtime(argv: Optional[Iterable[str]] = None) -> RuntimeOptions:
+ """Read backend flags before main.py resolves any filesystem paths."""
+
+ parser = argparse.ArgumentParser(add_help=False)
+ parser.add_argument("--host", default=os.getenv("CANVAS_HOST", "0.0.0.0"))
+ parser.add_argument("--port", type=int, default=int(os.getenv("CANVAS_PORT", "3000")))
+ parser.add_argument("--data-dir", default=os.getenv("CANVAS_DATA_DIR", ""))
+ parser.add_argument("--app-root", default=os.getenv("CANVAS_APP_ROOT", ""))
+ parser.add_argument("--portable-root", default=os.getenv("CANVAS_PORTABLE_ROOT", ""))
+ parser.add_argument("--desktop-token", default=os.getenv("CANVAS_DESKTOP_TOKEN", ""))
+ parser.add_argument("--parent-pid", type=int, default=int(os.getenv("CANVAS_PARENT_PID", "0") or 0))
+ parser.add_argument("--runtime-mode", default=os.getenv("CANVAS_RUNTIME_MODE", "source"))
+ parsed, _unknown = parser.parse_known_args(list(argv) if argv is not None else sys.argv[1:])
+
+ if not (1 <= parsed.port <= 65535):
+ raise ValueError(f"端口必须位于 1-65535:{parsed.port}")
+
+ env_updates = {
+ "CANVAS_HOST": parsed.host,
+ "CANVAS_PORT": str(parsed.port),
+ "CANVAS_DATA_DIR": parsed.data_dir,
+ "CANVAS_APP_ROOT": parsed.app_root,
+ "CANVAS_PORTABLE_ROOT": parsed.portable_root,
+ "CANVAS_DESKTOP_TOKEN": parsed.desktop_token,
+ "CANVAS_PARENT_PID": str(parsed.parent_pid),
+ "CANVAS_RUNTIME_MODE": parsed.runtime_mode,
+ }
+ for key, value in env_updates.items():
+ if value != "":
+ os.environ[key] = value
+
+ return RuntimeOptions(
+ host=str(parsed.host),
+ port=int(parsed.port),
+ data_dir=str(parsed.data_dir),
+ app_root=str(parsed.app_root),
+ portable_root=str(parsed.portable_root),
+ desktop_token=str(parsed.desktop_token),
+ parent_pid=max(0, int(parsed.parent_pid)),
+ mode=str(parsed.runtime_mode or "source"),
+ )
+
+
+RUNTIME_OPTIONS = bootstrap_runtime()
+
+_ACTIVE_SERVER = None
+
+
+def request_shutdown() -> bool:
+ server = _ACTIVE_SERVER
+ if server is None:
+ return False
+ server.should_exit = True
+ return True
+
+
+def _write_runtime_state(status: str) -> None:
+ root = Path(RUNTIME_OPTIONS.data_dir or os.getenv("CANVAS_DATA_DIR", "data")) / "run"
+ root.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "pid": os.getpid(),
+ "parent_pid": RUNTIME_OPTIONS.parent_pid,
+ "host": RUNTIME_OPTIONS.host,
+ "port": RUNTIME_OPTIONS.port,
+ "mode": RUNTIME_OPTIONS.mode,
+ "status": status,
+ "updated_at": int(time.time() * 1000),
+ }
+ temp = root / ".backend.json.tmp"
+ temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ os.replace(temp, root / "backend.json")
+ (root / "backend.pid").write_text(str(os.getpid()) + "\n", encoding="ascii")
+
+
+def _parent_alive(parent_pid: int) -> bool:
+ if parent_pid <= 0:
+ return True
+ if os.name != "nt":
+ try:
+ os.kill(parent_pid, 0)
+ return True
+ except OSError:
+ return False
+ import ctypes
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ handle = kernel32.OpenProcess(0x00100000, False, parent_pid)
+ if not handle:
+ return False
+ try:
+ return kernel32.WaitForSingleObject(handle, 0) == 0x00000102
+ finally:
+ kernel32.CloseHandle(handle)
+
+
+def _watch_parent() -> None:
+ while _parent_alive(RUNTIME_OPTIONS.parent_pid):
+ time.sleep(1)
+ request_shutdown()
+
+
+def run_uvicorn(app) -> None:
+ import uvicorn
+ global _ACTIVE_SERVER
+ config = uvicorn.Config(
+ app,
+ host=RUNTIME_OPTIONS.host,
+ port=RUNTIME_OPTIONS.port,
+ ws_ping_interval=None,
+ ws_ping_timeout=None,
+ workers=1,
+ )
+ server = uvicorn.Server(config)
+ _ACTIVE_SERVER = server
+ _write_runtime_state("starting")
+ if RUNTIME_OPTIONS.parent_pid:
+ threading.Thread(target=_watch_parent, name="canvas-parent-watch", daemon=True).start()
+ try:
+ _write_runtime_state("running")
+ server.run()
+ finally:
+ _write_runtime_state("stopped")
+ pid_file = Path(RUNTIME_OPTIONS.data_dir or os.getenv("CANVAS_DATA_DIR", "data")) / "run" / "backend.pid"
+ try:
+ pid_file.unlink()
+ except FileNotFoundError:
+ pass
+ _ACTIVE_SERVER = None
diff --git a/canvas_core/secrets.py b/canvas_core/secrets.py
new file mode 100644
index 000000000..926afc3e1
--- /dev/null
+++ b/canvas_core/secrets.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+import ctypes
+import os
+import re
+import time
+from ctypes import wintypes
+from pathlib import Path
+from typing import Callable, Iterable, Optional
+
+from .database import CanvasDatabase
+
+
+class SecretProtectionError(RuntimeError):
+ pass
+
+
+class _DataBlob(ctypes.Structure):
+ _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_ubyte))]
+
+
+def _blob(value: bytes) -> tuple[_DataBlob, ctypes.Array]:
+ buffer = ctypes.create_string_buffer(value)
+ return _DataBlob(len(value), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))), buffer
+
+
+class DpapiProtector:
+ PREFIX = b"CANVAS-DPAPI-1\0"
+ ENTROPY = b"Canvas Windows portable secrets v1"
+
+ def __init__(self) -> None:
+ if os.name != "nt":
+ raise SecretProtectionError("Windows DPAPI 仅能在 Windows 上使用")
+ self.crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
+ self.kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ self.crypt32.CryptProtectData.argtypes = [
+ ctypes.POINTER(_DataBlob),
+ wintypes.LPCWSTR,
+ ctypes.POINTER(_DataBlob),
+ ctypes.c_void_p,
+ ctypes.c_void_p,
+ wintypes.DWORD,
+ ctypes.POINTER(_DataBlob),
+ ]
+ self.crypt32.CryptProtectData.restype = wintypes.BOOL
+ self.crypt32.CryptUnprotectData.argtypes = [
+ ctypes.POINTER(_DataBlob),
+ ctypes.POINTER(wintypes.LPWSTR),
+ ctypes.POINTER(_DataBlob),
+ ctypes.c_void_p,
+ ctypes.c_void_p,
+ wintypes.DWORD,
+ ctypes.POINTER(_DataBlob),
+ ]
+ self.crypt32.CryptUnprotectData.restype = wintypes.BOOL
+ self.kernel32.LocalFree.argtypes = [ctypes.c_void_p]
+ self.kernel32.LocalFree.restype = ctypes.c_void_p
+
+ def protect(self, value: str) -> bytes:
+ raw = str(value).encode("utf-8")
+ source, source_buffer = _blob(raw)
+ entropy, entropy_buffer = _blob(self.ENTROPY)
+ output = _DataBlob()
+ if not self.crypt32.CryptProtectData(
+ ctypes.byref(source), "Canvas secret", ctypes.byref(entropy), None, None, 0x1, ctypes.byref(output)
+ ):
+ raise SecretProtectionError(f"DPAPI 加密失败,Windows 错误码 {ctypes.get_last_error()}")
+ try:
+ return self.PREFIX + ctypes.string_at(output.pbData, output.cbData)
+ finally:
+ self.kernel32.LocalFree(output.pbData)
+
+ def unprotect(self, value: bytes) -> str:
+ payload = bytes(value)
+ if not payload.startswith(self.PREFIX):
+ raise SecretProtectionError("密钥数据格式不受支持")
+ source, source_buffer = _blob(payload[len(self.PREFIX) :])
+ entropy, entropy_buffer = _blob(self.ENTROPY)
+ output = _DataBlob()
+ description = wintypes.LPWSTR()
+ if not self.crypt32.CryptUnprotectData(
+ ctypes.byref(source), ctypes.byref(description), ctypes.byref(entropy), None, None, 0x1, ctypes.byref(output)
+ ):
+ raise SecretProtectionError(f"DPAPI 解密失败,密钥可能来自其他 Windows 用户,错误码 {ctypes.get_last_error()}")
+ try:
+ return ctypes.string_at(output.pbData, output.cbData).decode("utf-8")
+ finally:
+ self.kernel32.LocalFree(output.pbData)
+ if description:
+ self.kernel32.LocalFree(description)
+
+
+def parse_env_text(raw: str) -> dict[str, str]:
+ values: dict[str, str] = {}
+ for raw_line in str(raw or "").splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ key = key.strip()
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
+ continue
+ value = value.strip()
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
+ value = value[1:-1]
+ values[key] = value
+ return values
+
+
+class SecretStore:
+ def __init__(
+ self,
+ database: CanvasDatabase,
+ protect: Optional[Callable[[str], bytes]] = None,
+ unprotect: Optional[Callable[[bytes], str]] = None,
+ ) -> None:
+ self.database = database
+ if protect is None or unprotect is None:
+ protector = DpapiProtector()
+ protect = protector.protect
+ unprotect = protector.unprotect
+ self._protect = protect
+ self._unprotect = unprotect
+
+ def get(self, key: str, default: str = "") -> str:
+ blob = self.database.load_secret_blob(str(key or "").strip())
+ if blob is None:
+ return default
+ try:
+ return self._unprotect(blob)
+ except SecretProtectionError:
+ return default
+
+ def set(self, key: str, value: str) -> None:
+ name = str(key or "").strip()
+ if not name:
+ raise ValueError("密钥名称不能为空")
+ text = str(value or "")
+ if not text:
+ self.database.delete_secret(name)
+ return
+ self.database.save_secret_blob(name, self._protect(text))
+
+ def update(self, values: dict[str, str]) -> None:
+ for key, value in values.items():
+ self.set(key, str(value or ""))
+
+ def all(self) -> dict[str, str]:
+ result: dict[str, str] = {}
+ for key, blob in self.database.list_secret_blobs().items():
+ try:
+ result[key] = self._unprotect(blob)
+ except SecretProtectionError:
+ continue
+ return result
+
+ def load_into_environ(self, overwrite: bool = False) -> None:
+ for key, value in self.all().items():
+ if overwrite or key not in os.environ:
+ os.environ[key] = value
+
+ def import_env_files(self, paths: Iterable[Path]) -> dict[str, int]:
+ candidates = [Path(path) for path in paths]
+ imported: dict[str, str] = {}
+ readable: list[Path] = []
+ for path in candidates:
+ if not path.is_file():
+ continue
+ values = parse_env_text(path.read_text(encoding="utf-8-sig"))
+ imported.update(values)
+ readable.append(path)
+ if not readable:
+ return {"files": 0, "values": 0, "removed": 0}
+ self.update(imported)
+ for key, expected in imported.items():
+ if self.get(key) != expected:
+ raise SecretProtectionError(f"DPAPI 导入校验失败:{key}")
+ removed = 0
+ for path in readable:
+ path.unlink()
+ removed += 1
+ self.database.put_document(
+ "security",
+ "secret_migration",
+ {
+ "status": "complete",
+ "files_removed": removed,
+ "values_imported": len(imported),
+ "completed_at": int(time.time() * 1000),
+ },
+ )
+ return {"files": len(readable), "values": len(imported), "removed": removed}
diff --git a/canvas_core/storage_bootstrap.py b/canvas_core/storage_bootstrap.py
new file mode 100644
index 000000000..b969a42a7
--- /dev/null
+++ b/canvas_core/storage_bootstrap.py
@@ -0,0 +1,25 @@
+from .data_layout import DataLayout
+from .database import CanvasDatabase
+from .migration import LegacyMigrator
+from .maintenance import MaintenanceManager
+from .paths import APP_PATHS
+from .secrets import SecretStore
+
+
+DATA_LAYOUT = DataLayout.from_app_paths(APP_PATHS)
+DATA_LAYOUT.ensure()
+
+MAINTENANCE = MaintenanceManager(DATA_LAYOUT)
+MAINTENANCE_REPORT = MAINTENANCE.run_once()
+MAINTENANCE.start()
+
+DATABASE = CanvasDatabase(DATA_LAYOUT.database_file)
+DATABASE.initialize()
+
+MIGRATION_REPORT = LegacyMigrator(APP_PATHS, DATA_LAYOUT, DATABASE).run()
+
+SECRET_STORE = SecretStore(DATABASE)
+_legacy_secret_files = sorted(DATA_LAYOUT.backups.glob("migration-*/legacy-root/API/.env"))
+_legacy_secret_files.append(DATA_LAYOUT.secret_env)
+SECRET_MIGRATION_REPORT = SECRET_STORE.import_env_files(_legacy_secret_files)
+SECRET_STORE.load_into_environ()
diff --git a/desktop-placeholder/index.html b/desktop-placeholder/index.html
new file mode 100644
index 000000000..74a08d915
--- /dev/null
+++ b/desktop-placeholder/index.html
@@ -0,0 +1 @@
+
CanvasCanvas 服务正在启动…
diff --git a/main.py b/main.py
index 74321e993..89886ca27 100644
--- a/main.py
+++ b/main.py
@@ -25,6 +25,7 @@
import shlex
import functools
import html
+import ipaddress
import xml.etree.ElementTree as ET
from typing import List, Dict, Any, Optional, Tuple
from threading import Lock, Thread
@@ -34,14 +35,49 @@
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Header, Request
from fastapi.exceptions import RequestValidationError
from fastapi.staticfiles import StaticFiles
-from fastapi.responses import FileResponse, Response, StreamingResponse, JSONResponse
+from fastapi.responses import FileResponse, Response, StreamingResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
from fastapi.middleware.cors import CORSMiddleware
+PROJECT_MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
+if PROJECT_MODULE_DIR not in sys.path:
+ sys.path.insert(0, PROJECT_MODULE_DIR)
+
+from canvas_core.paths import APP_PATHS
+from canvas_core.runtime import RUNTIME_OPTIONS, request_shutdown, run_uvicorn
+from canvas_core.storage_bootstrap import (
+ DATA_LAYOUT,
+ DATABASE,
+ MAINTENANCE_REPORT,
+ MIGRATION_REPORT,
+ SECRET_MIGRATION_REPORT,
+ SECRET_STORE,
+)
+from canvas_core.auth import AuthIdentity, AuthManager, SESSION_COOKIE
+from canvas_core.database import RevisionConflict
+from canvas_core.events import entity_changed
+from canvas_core.ecommerce import (
+ QUALITY_CHECKS as ECOMMERCE_QUALITY_CHECKS,
+ build_model_catalog as build_ecommerce_model_catalog,
+ build_prompt as build_ecommerce_prompt,
+ parse_garment_analysis as parse_ecommerce_garment_analysis,
+ parse_universal_reference_analysis as parse_ecommerce_universal_reference_analysis,
+ public_capabilities as ecommerce_public_capabilities,
+ resolve_generation_settings as resolve_ecommerce_generation_settings,
+ route_candidates as ecommerce_route_candidates,
+ safe_fallback_error as ecommerce_safe_fallback_error,
+ validate_input_roles as validate_ecommerce_input_roles,
+ validate_mode as validate_ecommerce_mode,
+ validate_operation as validate_ecommerce_operation,
+)
+
+AUTH_MANAGER = AuthManager(DATABASE, RUNTIME_OPTIONS.desktop_token)
+
QUIET_ACCESS_PATHS = {
- "/api/queue_status",
"/api/canvases",
"/api/canvases/trash",
+ "/api/auth/bootstrap",
+ "/api/runtime/shutdown",
}
QUIET_ACCESS_PREFIXES = (
"/api/canvases/",
@@ -67,11 +103,43 @@ def filter(self, record):
app = FastAPI()
+PUBLIC_HTTP_PATHS = {
+ "/pair",
+ "/favicon.ico",
+ "/api/health",
+ "/api/auth/status",
+ "/api/auth/pair",
+ "/api/auth/bootstrap",
+ "/api/runtime/shutdown",
+}
+
+
+def request_access_token(request: Request) -> str:
+ bearer = AUTH_MANAGER.bearer_token(request.headers.get("authorization", ""))
+ return bearer or request.cookies.get(SESSION_COOKIE, "")
+
+
+@app.middleware("http")
+async def authentication_middleware(request: Request, call_next):
+ path = request.url.path.rstrip("/") or "/"
+ if request.method == "OPTIONS" or path in PUBLIC_HTTP_PATHS:
+ return await call_next(request)
+ identity = AUTH_MANAGER.authenticate(request_access_token(request))
+ if identity:
+ request.state.auth_identity = identity
+ return await call_next(request)
+ if path == "/" or not path.startswith("/api/") and "text/html" in request.headers.get("accept", ""):
+ return RedirectResponse("/pair", status_code=307)
+ return JSONResponse({"detail": "设备尚未配对或会话已失效"}, status_code=401)
+
+
app.add_middleware(
CORSMiddleware,
- allow_origins=["*"],
- allow_methods=["*"],
- allow_headers=["*"],
+ allow_origins=["null", "http://127.0.0.1:3000", "http://localhost:3000"],
+ allow_origin_regex=r"(?:chrome-extension://[a-z]{32}|uxp://[^/]+|https?://(?:127\.0\.0\.1|localhost)(?::\d+)?)",
+ allow_credentials=True,
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ allow_headers=["Authorization", "Content-Type", "X-Canvas-Client", "X-User-Id"],
)
# --- WebSocket 状态管理器 ---
@@ -80,11 +148,15 @@ def __init__(self):
self.active_connections: List[WebSocket] = []
self.user_connections: Dict[str, WebSocket] = {}
self.connection_clients: Dict[WebSocket, str] = {}
+ self.connection_devices: Dict[WebSocket, str] = {}
- async def connect(self, websocket: WebSocket, client_id: str = None):
- await websocket.accept()
+ async def connect(self, websocket: WebSocket, client_id: str = None, identity: AuthIdentity = None, accept: bool = True):
+ if accept:
+ await websocket.accept()
self.active_connections.append(websocket)
self.connection_clients[websocket] = client_id or f"anon-{id(websocket)}"
+ if identity:
+ self.connection_devices[websocket] = identity.device_id
if client_id:
self.user_connections[client_id] = websocket
print(f"WS Connected. Total: {len(self.active_connections)}, Online: {self.online_count()}")
@@ -94,11 +166,21 @@ async def disconnect(self, websocket: WebSocket, client_id: str = None):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
self.connection_clients.pop(websocket, None)
+ self.connection_devices.pop(websocket, None)
if client_id and self.user_connections.get(client_id) is websocket:
del self.user_connections[client_id]
print(f"WS Disconnected. Total: {len(self.active_connections)}, Online: {self.online_count()}")
await self.broadcast_count()
+ async def disconnect_device(self, device_id: str):
+ targets = [ws for ws, current in self.connection_devices.items() if current == device_id]
+ for websocket in targets:
+ try:
+ await websocket.close(code=4403, reason="设备授权已撤销")
+ except Exception:
+ pass
+ await self.disconnect(websocket)
+
def online_count(self):
visible_clients = {
client_id for client_id in self.connection_clients.values()
@@ -117,38 +199,23 @@ async def broadcast_count(self):
self.active_connections.remove(connection)
async def broadcast_new_image(self, image_data: dict):
- data = json.dumps({"type": "new_image", "data": image_data})
- for connection in self.active_connections[:]:
- try:
- await connection.send_text(data)
- except Exception as e:
- print(f"Broadcast image error: {e}")
- self.active_connections.remove(connection)
+ return None
async def broadcast_canvas_updated(self, canvas_id: str, updated_at: int, client_id: str = ""):
- data = json.dumps({
- "type": "canvas_updated",
- "canvas_id": canvas_id,
- "updated_at": updated_at,
- "client_id": client_id or "",
- })
- for connection in self.active_connections[:]:
- try:
- await connection.send_text(data)
- except Exception as e:
- print(f"Broadcast canvas error: {e}")
- self.active_connections.remove(connection)
+ canvas = DATABASE.get_canvas(canvas_id) or {}
+ await self.broadcast_entity_changed("canvas", canvas_id, int(canvas.get("revision") or 0), client_id, updated_at)
async def broadcast_asset_library_updated(self, updated_at: int = 0):
- data = json.dumps({
- "type": "asset_library_updated",
- "updated_at": updated_at or now_ms(),
- })
+ revision = DATABASE.next_revision("asset", "global")
+ await self.broadcast_entity_changed("asset", "global", revision, updated_at=updated_at)
+
+ async def broadcast_entity_changed(self, topic: str, entity_id: str, revision: int, actor_id: str = "", updated_at: int = 0):
+ data = json.dumps(entity_changed(topic, entity_id, revision, actor_id, updated_at).public(), ensure_ascii=False)
for connection in self.active_connections[:]:
try:
await connection.send_text(data)
except Exception as e:
- print(f"Broadcast asset library error: {e}")
+ print(f"Broadcast entity event error: {e}")
self.active_connections.remove(connection)
async def send_personal_message(self, message: dict, client_id: str):
@@ -161,7 +228,7 @@ async def send_personal_message(self, message: dict, client_id: str):
manager = ConnectionManager()
GLOBAL_LOOP = None
-APP_VERSION = "2026.06.03"
+APP_VERSION = "1.0.16"
GITHUB_REPO_URL = "https://github.com/hero8152/Infinite-Canvas"
GITHUB_VERSION_URL = "https://raw.githubusercontent.com/hero8152/Infinite-Canvas/main/VERSION"
GITHUB_TREE_URL = "https://api.github.com/repos/hero8152/Infinite-Canvas/git/trees/main?recursive=1"
@@ -180,7 +247,7 @@ async def send_personal_message(self, message: dict, client_id: str):
async def startup_event():
global GLOBAL_LOOP
GLOBAL_LOOP = asyncio.get_running_loop()
- sync_static_html_versions()
+ # 程序资源在桌面包内按只读处理;静态资源版本号只在构建阶段写入暂存副本。
# 启动时整理资产库:给所有图片分组(含默认角色/场景)建好文件夹,并把根目录里的旧素材归整进去。
try:
await asyncio.to_thread(migrate_asset_library_into_dirs)
@@ -196,64 +263,105 @@ async def startup_event():
await asyncio.to_thread(migrate_mislabeled_image_extensions)
except Exception as exc:
print(f"纠正图片扩展名失败: {exc}")
+ try:
+ report = await asyncio.to_thread(prune_removed_provider_presets_once)
+ if report.get("removed"):
+ print(f"已移除未使用的预置 API 平台:{', '.join(report['removed'])}")
+ except Exception as exc:
+ print(f"清理未使用预置 API 平台失败: {exc}")
+ try:
+ await asyncio.to_thread(seed_builtin_local_vision_secret_once)
+ except Exception as exc:
+ print(f"初始化内置视觉模型失败: {exc}")
+ try:
+ await asyncio.to_thread(load_online_image_tasks_from_disk)
+ except Exception as exc:
+ print(f"在线生图任务恢复失败: {exc}")
+ try:
+ await asyncio.to_thread(load_ecommerce_tasks_from_disk)
+ except Exception as exc:
+ print(f"电商专用任务恢复失败: {exc}")
+@app.websocket("/ws/events")
@app.websocket("/ws/stats")
async def websocket_endpoint(websocket: WebSocket, client_id: str = None):
- await manager.connect(websocket, client_id)
+ await websocket.accept()
+ connected = False
try:
+ raw_auth = await asyncio.wait_for(websocket.receive_text(), timeout=5.0)
+ try:
+ auth_message = json.loads(raw_auth)
+ except json.JSONDecodeError:
+ auth_message = {}
+ if auth_message.get("type") != "auth":
+ await websocket.send_text(json.dumps({"type": "auth.failed", "message": "首条消息必须完成鉴权"}))
+ await websocket.close(code=4401)
+ return
+ token = str(auth_message.get("token") or "").strip()
+ token = token or websocket.cookies.get(SESSION_COOKIE, "")
+ token = token or AUTH_MANAGER.bearer_token(websocket.headers.get("authorization", ""))
+ identity = AUTH_MANAGER.authenticate(token)
+ if not identity:
+ await websocket.send_text(json.dumps({"type": "auth.failed", "message": "设备尚未配对或授权已失效"}))
+ await websocket.close(code=4401)
+ return
+ client_id = str(auth_message.get("client_id") or client_id or "").strip()
+ await manager.connect(websocket, client_id, identity, accept=False)
+ connected = True
+ await websocket.send_text(json.dumps({"type": "auth.ok", "device": identity.public()}))
while True:
data = await websocket.receive_text()
- if data == "ping":
+ if data == "ping" or data == '{"type":"ping"}':
await websocket.send_text(json.dumps({"type": "pong"}))
except WebSocketDisconnect:
- await manager.disconnect(websocket, client_id)
+ if connected:
+ await manager.disconnect(websocket, client_id)
+ except asyncio.TimeoutError:
+ await websocket.close(code=4401, reason="鉴权超时")
except Exception as e:
print(f"WS Error: {e}")
- await manager.disconnect(websocket, client_id)
+ if connected:
+ await manager.disconnect(websocket, client_id)
# --- 配置区域 ---
-CLIENT_ID = str(uuid.uuid4())
-BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-WORKFLOW_DIR = os.path.join(BASE_DIR, "workflows")
-WORKFLOW_PATH = os.path.join(WORKFLOW_DIR, "Z-Image.json")
-STATIC_DIR = os.path.join(BASE_DIR, "static")
+BASE_DIR = str(APP_PATHS.app_root)
+STATIC_DIR = str(APP_PATHS.web_root)
STATIC_RUNNINGHUB_DIR = os.path.join(STATIC_DIR, "runninghub")
STATIC_RUNNINGHUB_THUMBNAIL_DIR = os.path.join(STATIC_RUNNINGHUB_DIR, "thumbnails")
STATIC_RUNNINGHUB_API_PROVIDERS_FILE = os.path.join(STATIC_RUNNINGHUB_DIR, "api_providers.json")
STATIC_RUNNINGHUB_MODEL_REGISTRY_FILE = os.path.join(STATIC_RUNNINGHUB_DIR, "models_registry.json")
-OUTPUT_DIR = os.path.join(BASE_DIR, "output")
-ASSETS_DIR = os.path.join(BASE_DIR, "assets")
-OUTPUT_INPUT_DIR = os.path.join(ASSETS_DIR, "input")
-OUTPUT_OUTPUT_DIR = os.path.join(ASSETS_DIR, "output")
-ASSET_LIBRARY_DIR = os.path.join(ASSETS_DIR, "library")
-LOCAL_UPLOAD_DIR = os.path.join(ASSETS_DIR, "uploads")
-HISTORY_FILE = os.path.join(BASE_DIR, "history.json")
-API_ENV_FILE = os.path.join(BASE_DIR, "API", ".env")
-DATA_DIR = os.path.join(BASE_DIR, "data")
-CONVERSATION_DIR = os.path.join(DATA_DIR, "conversations")
-CANVAS_DIR = os.path.join(DATA_DIR, "canvases")
-MEDIA_PREVIEW_DIR = os.path.join(DATA_DIR, "media_previews")
-ASSET_LIBRARY_PATH = os.path.join(DATA_DIR, "asset_library.json")
-PROMPT_LIBRARY_PATH = os.path.join(DATA_DIR, "prompt_libraries.json")
-API_PROVIDERS_FILE = os.path.join(DATA_DIR, "api_providers.json")
-RUNNINGHUB_WORKFLOW_STORE_FILE = os.path.join(DATA_DIR, "runninghub_workflows.json")
-SHARED_FOLDERS_FILE = os.path.join(DATA_DIR, "shared_folders.json")
-GLOBAL_CONFIG_FILE = os.path.join(BASE_DIR, "global_config.json")
+OUTPUT_DIR = str(DATA_LAYOUT.exports)
+ASSETS_DIR = str(DATA_LAYOUT.media)
+OUTPUT_INPUT_DIR = str(DATA_LAYOUT.media_input)
+OUTPUT_OUTPUT_DIR = str(DATA_LAYOUT.media_generated)
+ASSET_LIBRARY_DIR = str(DATA_LAYOUT.media_library)
+LOCAL_UPLOAD_DIR = str(DATA_LAYOUT.media_uploads)
+HISTORY_FILE = ""
+API_ENV_FILE = str(DATA_LAYOUT.secret_env)
+DATA_DIR = str(APP_PATHS.data_root)
+CONVERSATION_DIR = ""
+CANVAS_DIR = ""
+MEDIA_PREVIEW_DIR = str(DATA_LAYOUT.cache_previews)
+ASSET_LIBRARY_PATH = ""
+PROMPT_LIBRARY_PATH = ""
+API_PROVIDERS_FILE = ""
+RUNNINGHUB_WORKFLOW_STORE_FILE = ""
+SHARED_FOLDERS_FILE = ""
+ONLINE_IMAGE_TASKS_FILE = ""
+GLOBAL_CONFIG_FILE = ""
CANVAS_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
LOCAL_IMAGE_IMPORT_MAX_BYTES = int(os.getenv("LOCAL_IMAGE_IMPORT_MAX_BYTES", str(50 * 1024 * 1024)))
LOCAL_IMAGE_IMPORT_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
RUNNINGHUB_THUMBNAIL_EXTS = (".jpg",)
-QUEUE = []
-QUEUE_LOCK = Lock()
HISTORY_LOCK = Lock()
GLOBAL_CONFIG_LOCK = Lock()
CONVERSATION_LOCK = Lock()
CANVAS_LOCK = Lock()
-LOAD_LOCK = Lock()
RUNNINGHUB_WORKFLOW_LOCK = Lock()
-NEXT_TASK_ID = 1
+ONLINE_IMAGE_TASK_LOCK = Lock()
+ECOMMERCE_TASK_LOCK = Lock()
UPDATE_LOCK = Lock()
JIMENG_LOGIN_SESSION = {
"proc": None,
@@ -263,12 +371,34 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str = None):
}
PROVIDER_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{2,40}$")
-SUPPORTED_PROVIDER_PROTOCOLS = {"openai", "apimart", "gemini", "volcengine", "runninghub", "jimeng"}
+SUPPORTED_PROVIDER_PROTOCOLS = {"openai", "apimart", "gemini", "volcengine", "runninghub", "jimeng", "codex"}
SUPPORTED_IMAGE_REQUEST_MODES = {"openai", "openai-json"}
RUNNINGHUB_DEFAULT_BASE_URL = "https://www.runninghub.cn"
RUNNINGHUB_OPENAPI_BASE_URL = "https://www.runninghub.cn/openapi/v2"
RUNNINGHUB_MODEL_REGISTRY_URL = "https://raw.githubusercontent.com/HM-RunningHub/ComfyUI_RH_OpenAPI/main/models_registry.json"
RUNNINGHUB_LLM_BASE_URL = "https://llm.runninghub.cn/v1"
+GRSAI_DEFAULT_BASE_URL = "https://grsaiapi.com"
+GRSAI_DEFAULT_IMAGE_MODELS = [
+ "nano-banana",
+ "nano-banana-fast",
+ "nano-banana-2",
+ "nano-banana-2-cl",
+ "nano-banana-2-2k-cl",
+ "nano-banana-2-4k-cl",
+ "nano-banana-pro",
+ "nano-banana-pro-vt",
+ "nano-banana-pro-cl",
+ "nano-banana-pro-vip",
+ "nano-banana-pro-4k-vip",
+ "gpt-image-2",
+ "gpt-image-2-vip",
+]
+SHIYING_DEFAULT_BASE_URL = "https://www.shiying-api.com"
+SHIYING_DEFAULT_IMAGE_MODELS = ["gemini-3-pro-image-preview"]
+LOCAL_VISION_DEFAULT_BASE_URL = "http://115.231.35.105:12345/v1"
+LOCAL_VISION_DEFAULT_MODEL = "qwen3.5-9b-vlm"
+LOCAL_VISION_BUILTIN_API_KEY = "sk-lm-VF0plfgx:ZdOB4jyCcB63K1N1tIQg"
+LOCAL_VISION_SECRET_SEED_SETTING = "local_vision_builtin_secret_v1"
LINGJING_DEFAULT_BASE_URL = "https://apistudio.vip"
RUNNINGHUB_LLM_MODELS_URLS = [
"https://llm.runninghub.cn/v1/models",
@@ -299,6 +429,12 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str = None):
"3.0",
"3.0fast",
]
+CODEX_DEFAULT_IMAGE_MODELS = ["$imagegen"]
+CODEX_DEFAULT_CHAT_MODELS = ["gpt-5.5"]
+try:
+ CODEX_DEFAULT_TIMEOUT = max(30, min(3600, int(os.getenv("CODEX_CLI_TIMEOUT", "900"))))
+except Exception:
+ CODEX_DEFAULT_TIMEOUT = 900
AGNES_DEFAULT_VIDEO_MODELS = ["agnes-video-v2.0"]
JIMENG_LEGACY_IMAGE_MODELS = {
"jimeng-image-2k",
@@ -426,37 +562,14 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str = None):
]
def ensure_runtime_config_files():
- """首次运行时提前创建配置目录,避免第一次保存 API Key 时才创建目录/文件。"""
- try:
- os.makedirs(os.path.dirname(API_ENV_FILE), exist_ok=True)
- os.makedirs(DATA_DIR, exist_ok=True)
- if not os.path.exists(API_ENV_FILE):
- with open(API_ENV_FILE, "a", encoding="utf-8"):
- pass
- except Exception as e:
- print(f"初始化 API 配置目录失败: {e}")
+ """配置目录由 data 布局创建;密钥只写 DPAPI 存储,不再创建明文 env。"""
+ os.makedirs(DATA_DIR, exist_ok=True)
def load_env_file():
- if not os.path.exists(API_ENV_FILE):
- return
- try:
- with open(API_ENV_FILE, 'r', encoding='utf-8-sig') as f:
- for raw_line in f.read().splitlines():
- line = raw_line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- key, value = line.split("=", 1)
- key = key.strip()
- value = value.strip().strip('"').strip("'")
- os.environ.setdefault(key, value)
- except Exception as e:
- print(f"加载 API/.env 失败: {e}")
+ SECRET_STORE.load_into_environ()
ensure_runtime_config_files()
load_env_file()
-COMFYUI_INSTANCES = [s.strip() for s in os.getenv("COMFYUI_INSTANCES", "127.0.0.1:8188").split(",") if s.strip()]
-COMFYUI_ADDRESS = COMFYUI_INSTANCES[0]
-
AI_BASE_URL = os.getenv("COMFLY_BASE_URL", "https://ai.comfly.chat").rstrip("/")
AI_API_KEY = os.getenv("COMFLY_API_KEY", "")
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").strip().rstrip("/")
@@ -512,10 +625,6 @@ def load_env_file():
AI_REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", "1800"))
IMAGE_POLL_INTERVAL = float(os.getenv("IMAGE_POLL_INTERVAL", "2"))
IMAGE_TASK_TIMEOUT = float(os.getenv("IMAGE_TASK_TIMEOUT", str(AI_REQUEST_TIMEOUT)))
-COMFYUI_HISTORY_TIMEOUT = int(float(os.getenv("COMFYUI_HISTORY_TIMEOUT", "1800")))
-# 下载 ComfyUI 产物的 socket 超时(秒,作用于连接和每次 read)。没有它时一次网络卡顿会让 urlopen 永久挂起,
-# 导致 generate() 不返回、画布卡片一直转圈拿不到结果。给得足够大以容纳大视频/大图的正常下载。
-COMFYUI_DOWNLOAD_TIMEOUT = float(os.getenv("COMFYUI_DOWNLOAD_TIMEOUT", "120"))
APIMART_IMAGE_TASK_TIMEOUT = float(os.getenv("APIMART_IMAGE_TASK_TIMEOUT", "1800"))
APIMART_IMAGE_POLL_INTERVAL = float(os.getenv("APIMART_IMAGE_POLL_INTERVAL", "5"))
APIMART_IMAGE_INITIAL_POLL_DELAY = float(os.getenv("APIMART_IMAGE_INITIAL_POLL_DELAY", "10"))
@@ -620,6 +729,8 @@ def reload_env_globals():
def provider_key_env(provider_id):
if provider_id == "comfly":
return "COMFLY_API_KEY"
+ if provider_id == "grsai":
+ return "GRSAI_API_KEY"
if provider_id == "modelscope":
return "MODELSCOPE_API_KEY"
if provider_id == "runninghub":
@@ -639,20 +750,7 @@ def volcengine_secret_key_env():
def read_api_env_value(key: str) -> str:
key = str(key or "").strip()
- if not key or not os.path.exists(API_ENV_FILE):
- return ""
- try:
- with open(API_ENV_FILE, "r", encoding="utf-8-sig") as f:
- for raw_line in f.read().splitlines():
- line = raw_line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- env_key, value = line.split("=", 1)
- if env_key.strip() == key:
- return value.strip().strip('"').strip("'")
- except Exception:
- return ""
- return ""
+ return SECRET_STORE.get(key, "") if key else ""
def provider_env_key_value(provider_id: str) -> str:
provider_id = str(provider_id or "").strip().lower()
@@ -699,8 +797,12 @@ def bearer_auth_value(value):
token = strip_auth_scheme(value, "Bearer")
return f"Bearer {token}" if token else ""
-def default_api_providers():
- # 独立入口平台强制保留,其他平台均可自定义增删
+REMOVED_PROVIDER_PRESET_IDS = {"modelscope", "runninghub", "volcengine", "lingjing", "codex"}
+PROVIDER_PRESET_CLEANUP_SETTING = "provider_preset_cleanup_grsai_v1"
+
+
+def api_provider_templates():
+ # 保留完整模板供协议兼容代码参考;默认平台列表只启用当前实际使用的 Grsai。
return [
{
"id": "modelscope",
@@ -754,6 +856,57 @@ def default_api_providers():
"volcengine_project_name": VOLCENGINE_DEFAULT_PROJECT_NAME,
"volcengine_region": VOLCENGINE_DEFAULT_REGION,
},
+ {
+ "id": "grsai",
+ "name": "Grsai",
+ "base_url": GRSAI_DEFAULT_BASE_URL,
+ "protocol": "openai",
+ "image_request_mode": "openai",
+ "image_generation_endpoint": "",
+ "image_edit_endpoint": "",
+ "enabled": True,
+ "primary": False,
+ "image_models": GRSAI_DEFAULT_IMAGE_MODELS,
+ "chat_models": [],
+ "video_models": [],
+ "model_protocols": {},
+ "ms_loras": [],
+ "ms_defaults_version": 0,
+ },
+ {
+ "id": "shiying",
+ "name": "shiying",
+ "base_url": SHIYING_DEFAULT_BASE_URL,
+ "protocol": "openai",
+ "image_request_mode": "openai",
+ "image_generation_endpoint": "",
+ "image_edit_endpoint": "",
+ "enabled": True,
+ "primary": False,
+ "image_models": SHIYING_DEFAULT_IMAGE_MODELS,
+ "chat_models": [],
+ "video_models": [],
+ "model_protocols": {"gemini-3-pro-image-preview": "gemini"},
+ "ms_loras": [],
+ "ms_defaults_version": 0,
+ },
+ {
+ "id": "local-vision",
+ "name": "本地视觉模型",
+ "base_url": LOCAL_VISION_DEFAULT_BASE_URL,
+ "protocol": "openai",
+ "image_request_mode": "openai",
+ "image_generation_endpoint": "",
+ "image_edit_endpoint": "",
+ "enabled": True,
+ "primary": False,
+ "image_models": [],
+ "chat_models": [LOCAL_VISION_DEFAULT_MODEL],
+ "video_models": [],
+ "model_protocols": {},
+ "ms_loras": [],
+ "ms_defaults_version": 0,
+ },
{
"id": "lingjing",
"name": "灵境API",
@@ -771,8 +924,28 @@ def default_api_providers():
"ms_loras": [],
"ms_defaults_version": 0,
},
+ {
+ "id": "codex",
+ "name": "OpenAI CLI",
+ "base_url": "",
+ "protocol": "codex",
+ "image_request_mode": "openai",
+ "image_generation_endpoint": "",
+ "image_edit_endpoint": "",
+ "enabled": True,
+ "primary": False,
+ "image_models": CODEX_DEFAULT_IMAGE_MODELS,
+ "chat_models": CODEX_DEFAULT_CHAT_MODELS,
+ "video_models": [],
+ "ms_loras": [],
+ "ms_defaults_version": 0,
+ },
]
+
+def default_api_providers():
+ return [dict(item) for item in api_provider_templates() if item.get("id") in {"grsai", "shiying", "local-vision"}]
+
def merge_default_api_providers(providers):
merged = [dict(item) for item in providers]
# 强制保留独立入口平台(不再强制 comfly)
@@ -832,6 +1005,44 @@ def merge_default_api_providers(providers):
current["protocol"] = "volcengine"
current["volcengine_project_name"] = str(current.get("volcengine_project_name") or VOLCENGINE_DEFAULT_PROJECT_NAME).strip() or VOLCENGINE_DEFAULT_PROJECT_NAME
current["volcengine_region"] = str(current.get("volcengine_region") or VOLCENGINE_DEFAULT_REGION).strip() or VOLCENGINE_DEFAULT_REGION
+ grsai_default = next((d for d in default_api_providers() if d["id"] == "grsai"), None)
+ if grsai_default:
+ current = next((item for item in merged if item.get("id") == "grsai"), None)
+ if not current:
+ merged.append(grsai_default)
+ else:
+ if not current.get("base_url"):
+ current["base_url"] = grsai_default["base_url"]
+ if not current.get("protocol"):
+ current["protocol"] = "openai"
+ current["image_request_mode"] = normalize_image_request_mode(current.get("image_request_mode"))
+ current["image_models"] = model_list_from_values([*(current.get("image_models") or []), *(grsai_default.get("image_models") or [])])
+ current["chat_models"] = model_list_from_values(current.get("chat_models") or [])
+ current["video_models"] = model_list_from_values(current.get("video_models") or [])
+ shiying_default = next((d for d in default_api_providers() if d["id"] == "shiying"), None)
+ if shiying_default:
+ current = next((item for item in merged if item.get("id") == "shiying"), None)
+ if not current:
+ merged.append(shiying_default)
+ else:
+ if not current.get("base_url"):
+ current["base_url"] = shiying_default["base_url"]
+ current["image_models"] = model_list_from_values([*(current.get("image_models") or []), *SHIYING_DEFAULT_IMAGE_MODELS])
+ protocols = normalize_model_protocols(current.get("model_protocols"))
+ protocols.update(shiying_default["model_protocols"])
+ current["model_protocols"] = protocols
+ local_vision_default = next((d for d in default_api_providers() if d["id"] == "local-vision"), None)
+ if local_vision_default:
+ current = next((item for item in merged if item.get("id") == "local-vision"), None)
+ if not current:
+ merged.append(local_vision_default)
+ else:
+ current["name"] = str(current.get("name") or local_vision_default["name"])
+ current["base_url"] = str(current.get("base_url") or local_vision_default["base_url"])
+ current["protocol"] = "openai"
+ current["image_models"] = []
+ current["chat_models"] = model_list_from_values(current.get("chat_models") or local_vision_default["chat_models"])
+ current["video_models"] = []
lingjing_default = next((d for d in default_api_providers() if d["id"] == "lingjing"), None)
if lingjing_default:
current = next((item for item in merged if item.get("id") == "lingjing"), None)
@@ -849,6 +1060,18 @@ def merge_default_api_providers(providers):
protocols = normalize_model_protocols(current.get("model_protocols"))
protocols.update(normalize_model_protocols(lingjing_default.get("model_protocols")))
current["model_protocols"] = protocols
+ codex_default = next((d for d in default_api_providers() if d["id"] == "codex"), None)
+ if codex_default:
+ current = next((item for item in merged if item.get("id") == "codex"), None)
+ if not current:
+ merged.append(codex_default)
+ else:
+ current["protocol"] = "codex"
+ current["base_url"] = ""
+ current["image_request_mode"] = "openai"
+ current["image_models"] = model_list_from_values([*(current.get("image_models") or []), *CODEX_DEFAULT_IMAGE_MODELS])
+ current["chat_models"] = model_list_from_values([*(current.get("chat_models") or []), *CODEX_DEFAULT_CHAT_MODELS])
+ current["video_models"] = []
# 即梦 CLI 不再是强制保留的默认平台:仅在用户已添加了即梦协议的平台时,规范化其默认模型/地址。
for current in merged:
if not is_jimeng_provider(current):
@@ -863,7 +1086,10 @@ def merge_default_api_providers(providers):
*[item for item in (current.get("video_models") or []) if str(item or "").strip() not in JIMENG_LEGACY_VIDEO_MODELS],
*JIMENG_DEFAULT_VIDEO_MODELS,
])
- return merged
+ return [
+ item for item in merged
+ if str(item.get("id") or "").strip().lower() not in REMOVED_PROVIDER_PRESET_IDS
+ ]
def normalize_model_list(values):
return model_list_from_values(values)
@@ -1143,12 +1369,48 @@ def runninghub_openapi_url(provider, path=""):
base = runninghub_openapi_base_url(provider)
return f"{base}/{path}" if path else base
+def normalize_openai_compatible_base_url(value: str) -> str:
+ text = str(value or "").strip().replace(":", ":")
+ if not text:
+ return ""
+ if len(text) > 300 or re.search(r"\s", text):
+ raise HTTPException(status_code=400, detail="视觉模型请求地址不合法")
+ if not re.match(r"^https?://", text, re.I):
+ host_hint = text.split("/", 1)[0]
+ bare_host = host_hint.rsplit("@", 1)[-1]
+ if bare_host.startswith("["):
+ bare_host = bare_host[1:bare_host.find("]")] if "]" in bare_host else bare_host
+ elif bare_host.count(":") == 1:
+ bare_host = bare_host.split(":", 1)[0]
+ use_http = bare_host.lower() == "localhost"
+ try:
+ ipaddress.ip_address(bare_host)
+ use_http = True
+ except ValueError:
+ pass
+ text = f"{'http' if use_http else 'https'}://{text}"
+ parsed = urllib.parse.urlsplit(text)
+ if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password:
+ raise HTTPException(status_code=400, detail="视觉模型请求地址不合法")
+ try:
+ parsed.port
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail="视觉模型端口不合法") from exc
+ if parsed.query or parsed.fragment:
+ raise HTTPException(status_code=400, detail="视觉模型请求地址不能包含查询参数或片段")
+ path = parsed.path.rstrip("/")
+ if not path:
+ path = "/v1"
+ return urllib.parse.urlunsplit((parsed.scheme.lower(), parsed.netloc, path, "", ""))
+
def normalize_provider(item):
provider_id = str(item.get("id") or "").strip().lower()
if not PROVIDER_ID_RE.fullmatch(provider_id):
raise HTTPException(status_code=400, detail=f"API 平台 ID 不合法:{provider_id or '(empty)'}")
name = re.sub(r"\s+", " ", str(item.get("name") or provider_id).strip())[:60] or provider_id
base_url = str(item.get("base_url") or "").strip().rstrip("/")
+ if provider_id == "local-vision":
+ base_url = normalize_openai_compatible_base_url(base_url or LOCAL_VISION_DEFAULT_BASE_URL)
if base_url and not re.match(r"^https?://", base_url):
raise HTTPException(status_code=400, detail=f"{name} 的 Base URL 需要以 http:// 或 https:// 开头")
protocol = str(item.get("protocol") or "openai").strip().lower()
@@ -1167,9 +1429,15 @@ def normalize_provider(item):
if provider_id == "jimeng":
protocol = "jimeng"
base_url = ""
+ if provider_id == "codex":
+ protocol = "codex"
+ base_url = ""
if provider_id == "runninghub":
protocol = "runninghub"
base_url = base_url or RUNNINGHUB_DEFAULT_BASE_URL
+ if provider_id == "local-vision":
+ protocol = "openai"
+ image_request_mode = "openai"
return {
"id": provider_id,
"name": name,
@@ -1180,9 +1448,9 @@ def normalize_provider(item):
"image_edit_endpoint": image_edit_endpoint,
"enabled": bool(item.get("enabled", True)),
"primary": bool(item.get("primary", False)),
- "image_models": model_list_from_values(item.get("image_models") or []),
+ "image_models": [] if provider_id == "local-vision" else model_list_from_values(item.get("image_models") or []),
"chat_models": model_list_from_values(item.get("chat_models") or []),
- "video_models": model_list_from_values(item.get("video_models") or []),
+ "video_models": [] if provider_id == "local-vision" else model_list_from_values(item.get("video_models") or []),
"model_protocols": normalize_model_protocols(item.get("model_protocols")),
"ms_loras": normalize_ms_loras(item.get("ms_loras") or []),
"ms_defaults_version": int(item.get("ms_defaults_version") or 0),
@@ -1194,22 +1462,50 @@ def normalize_provider(item):
def load_api_providers():
defaults = default_api_providers()
- if not os.path.exists(API_PROVIDERS_FILE):
+ raw = DATABASE.load_providers()
+ if not raw:
return merge_default_api_providers(defaults)
try:
- with open(API_PROVIDERS_FILE, "r", encoding="utf-8") as f:
- raw = json.load(f)
- providers = [normalize_provider(item) for item in raw if isinstance(item, dict)]
+ providers = [
+ normalize_provider(item)
+ for item in raw
+ if isinstance(item, dict)
+ and str(item.get("id") or "").strip().lower() not in REMOVED_PROVIDER_PRESET_IDS
+ ]
return merge_default_api_providers(providers or defaults)
except Exception as e:
print(f"加载 API 平台配置失败: {e}")
return defaults
+def prune_removed_provider_presets_once() -> Dict[str, Any]:
+ marker = DATABASE.get_setting(PROVIDER_PRESET_CLEANUP_SETTING, {})
+ value = marker.get("value") if isinstance(marker, dict) else {}
+ if isinstance(value, dict) and value.get("done"):
+ return {"removed": [], "skipped": True}
+ raw = DATABASE.load_providers()
+ rows = [item for item in raw if isinstance(item, dict)]
+ removed = [
+ str(item.get("id") or "").strip().lower()
+ for item in rows
+ if str(item.get("id") or "").strip().lower() in REMOVED_PROVIDER_PRESET_IDS
+ ]
+ kept = [
+ item for item in rows
+ if str(item.get("id") or "").strip().lower() not in REMOVED_PROVIDER_PRESET_IDS
+ ]
+ if removed:
+ DATABASE.save_providers(kept)
+ DATABASE.save_setting(
+ PROVIDER_PRESET_CLEANUP_SETTING,
+ {"done": True, "removed": removed, "completed_at": int(time.time() * 1000)},
+ only_if_empty=True,
+ )
+ return {"removed": removed, "kept": [str(item.get("id") or "") for item in kept], "skipped": False}
+
def save_api_providers(providers):
- os.makedirs(DATA_DIR, exist_ok=True)
with GLOBAL_CONFIG_LOCK:
- with open(API_PROVIDERS_FILE, "w", encoding="utf-8") as f:
- json.dump(providers, f, ensure_ascii=False, indent=2)
+ DATABASE.save_providers(providers)
+ publish_entity_changed("platform", "global")
def public_provider(provider):
if provider.get("id") == "runninghub":
@@ -1310,33 +1606,29 @@ def env_quote(value):
return text
def update_env_values(updates):
- os.makedirs(os.path.dirname(API_ENV_FILE), exist_ok=True)
- lines = []
- if os.path.exists(API_ENV_FILE):
- with open(API_ENV_FILE, "r", encoding="utf-8-sig") as f:
- lines = f.read().splitlines()
- seen = set()
- next_lines = []
- for line in lines:
- stripped = line.strip()
- if not stripped or stripped.startswith("#") or "=" not in line:
- next_lines.append(line)
- continue
- key = line.split("=", 1)[0].strip()
- if key in updates:
- next_lines.append(f"{key}={env_quote(updates[key])}")
- os.environ[key] = str(updates[key] or "")
- seen.add(key)
- else:
- next_lines.append(line)
+ SECRET_STORE.update({str(key): str(value or "") for key, value in updates.items()})
for key, value in updates.items():
- if key not in seen:
- next_lines.append(f"{key}={env_quote(value)}")
- os.environ[key] = str(value or "")
- with open(API_ENV_FILE, "w", encoding="utf-8") as f:
- f.write("\n".join(next_lines).rstrip() + "\n")
-
-BACKEND_LOCAL_LOAD = {addr: 0 for addr in COMFYUI_INSTANCES}
+ if value:
+ os.environ[str(key)] = str(value)
+ else:
+ os.environ.pop(str(key), None)
+
+def seed_builtin_local_vision_secret_once() -> Dict[str, Any]:
+ marker = DATABASE.get_setting(LOCAL_VISION_SECRET_SEED_SETTING, {})
+ marker_value = marker.get("value") if isinstance(marker, dict) else {}
+ if isinstance(marker_value, dict) and marker_value.get("done"):
+ return {"seeded": False, "skipped": True}
+ key_env = provider_key_env("local-vision")
+ seeded = False
+ if not provider_env_key_value("local-vision"):
+ update_env_values({key_env: LOCAL_VISION_BUILTIN_API_KEY})
+ seeded = True
+ DATABASE.save_setting(
+ LOCAL_VISION_SECRET_SEED_SETTING,
+ {"done": True, "seeded": seeded, "completed_at": int(time.time() * 1000)},
+ only_if_empty=True,
+ )
+ return {"seeded": seeded, "skipped": False}
os.makedirs(OUTPUT_DIR, exist_ok=True)
os.makedirs(ASSETS_DIR, exist_ok=True)
@@ -1344,19 +1636,20 @@ def update_env_values(updates):
os.makedirs(OUTPUT_OUTPUT_DIR, exist_ok=True)
os.makedirs(ASSET_LIBRARY_DIR, exist_ok=True)
os.makedirs(LOCAL_UPLOAD_DIR, exist_ok=True)
-os.makedirs(STATIC_DIR, exist_ok=True)
-os.makedirs(WORKFLOW_DIR, exist_ok=True)
-os.makedirs(CONVERSATION_DIR, exist_ok=True)
-os.makedirs(CANVAS_DIR, exist_ok=True)
+# static 和内置 workflows 属于只读程序资源,不在运行时创建或改写。
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
app.mount("/output", StaticFiles(directory=OUTPUT_DIR), name="output")
+app.mount("/assets/input", StaticFiles(directory=OUTPUT_INPUT_DIR), name="assets-input")
+app.mount("/assets/output", StaticFiles(directory=OUTPUT_OUTPUT_DIR), name="assets-output")
+app.mount("/assets/library", StaticFiles(directory=ASSET_LIBRARY_DIR), name="assets-library")
+app.mount("/assets/uploads", StaticFiles(directory=LOCAL_UPLOAD_DIR), name="assets-uploads")
app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets")
# --- Pydantic 模型 ---
def current_app_version():
- version_file = os.path.join(BASE_DIR, "VERSION")
+ version_file = str(APP_PATHS.version_file)
try:
if os.path.exists(version_file):
with open(version_file, "r", encoding="utf-8") as f:
@@ -1641,6 +1934,8 @@ def app_info():
version = current_app_version()
return {
"version": version,
+ "update_enabled": RUNTIME_OPTIONS.mode != "desktop",
+ "update_strategy": "replace-portable-package" if RUNTIME_OPTIONS.mode == "desktop" else "source-files",
"repo_url": GITHUB_REPO_URL,
"version_url": GITHUB_VERSION_URL,
"tree_url": GITHUB_TREE_URL,
@@ -1663,6 +1958,172 @@ def app_info():
"update_notes": read_local_update_notes(version),
}
+
+@app.get("/api/health")
+def health_check():
+ return {
+ "status": "ok",
+ "version": current_app_version(),
+ "pid": os.getpid(),
+ "runtime_mode": RUNTIME_OPTIONS.mode,
+ "database": "ok",
+ "schema_version": DATABASE.SCHEMA_VERSION,
+ }
+
+
+class PairDeviceRequest(BaseModel):
+ code: str
+ name: str = ""
+ client_type: str = "browser"
+
+
+PREFERENCE_KEYS = {
+ "theme",
+ "language",
+ "ui_scale",
+ "default_image_provider",
+ "default_image_model",
+ "default_video_provider",
+ "default_video_model",
+ "default_chat_provider",
+ "default_chat_model",
+ "ecommerce_settings",
+}
+
+
+class PreferencesUpdateRequest(BaseModel):
+ values: Dict[str, Any] = Field(default_factory=dict)
+ base_revision: int = 0
+ actor_id: str = ""
+ import_if_empty: bool = False
+
+
+@app.get("/pair")
+def pair_page():
+ return FileResponse(os.path.join(STATIC_DIR, "pair.html"), media_type="text/html")
+
+
+@app.get("/devices")
+def devices_page():
+ return FileResponse(os.path.join(STATIC_DIR, "devices.html"), media_type="text/html")
+
+
+@app.get("/api/auth/status")
+def auth_status(request: Request):
+ identity = AUTH_MANAGER.authenticate(request_access_token(request))
+ return {"authenticated": bool(identity), "device": identity.public() if identity else None}
+
+
+@app.get("/api/auth/bootstrap")
+def desktop_bootstrap(token: str = ""):
+ try:
+ session_token, _identity = AUTH_MANAGER.consume_desktop_token(token)
+ except PermissionError as exc:
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
+ response = RedirectResponse("/", status_code=303)
+ response.set_cookie(SESSION_COOKIE, session_token, httponly=True, samesite="strict", path="/")
+ return response
+
+
+@app.post("/api/auth/pair")
+def pair_device(payload: PairDeviceRequest):
+ try:
+ access_token, identity = AUTH_MANAGER.pair(payload.code, payload.name, payload.client_type)
+ except PermissionError as exc:
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
+ response = JSONResponse({"ok": True, "access_token": access_token, "device": identity.public()})
+ response.set_cookie(
+ SESSION_COOKIE,
+ access_token,
+ max_age=365 * 24 * 60 * 60,
+ httponly=True,
+ samesite="strict",
+ path="/",
+ )
+ return response
+
+
+@app.post("/api/auth/pair-code")
+def create_pair_code():
+ code, expires_at = AUTH_MANAGER.create_pair_code()
+ return {"code": code, "expires_at": expires_at, "ttl_seconds": 300}
+
+
+@app.get("/api/auth/devices")
+def paired_devices():
+ return {"devices": AUTH_MANAGER.list_devices()}
+
+
+@app.delete("/api/auth/devices/{device_id}")
+async def revoke_paired_device(device_id: str):
+ if not AUTH_MANAGER.revoke(device_id):
+ raise HTTPException(status_code=404, detail="设备不存在或已撤销")
+ await manager.disconnect_device(device_id)
+ return {"ok": True}
+
+
+@app.get("/api/preferences")
+def get_preferences():
+ record = DATABASE.get_setting("global_preferences", {})
+ values = record.get("value") if isinstance(record.get("value"), dict) else {}
+ return {"values": values, "revision": record["revision"], "updated_at": record["updated_at"]}
+
+
+@app.put("/api/preferences")
+def save_preferences(payload: PreferencesUpdateRequest):
+ clean = {key: value for key, value in payload.values.items() if key in PREFERENCE_KEYS and value not in (None, "")}
+ before = DATABASE.get_setting("global_preferences", {})
+ try:
+ record = DATABASE.save_setting(
+ "global_preferences",
+ clean,
+ base_revision=payload.base_revision,
+ only_if_empty=payload.import_if_empty,
+ )
+ except RevisionConflict as exc:
+ raise HTTPException(status_code=409, detail={
+ "message": "全局偏好已被其他设备修改",
+ "values": exc.value if isinstance(exc.value, dict) else {},
+ "revision": exc.revision,
+ }) from exc
+ if int(record["revision"]) != int(before["revision"]):
+ publish_entity_changed("preference", "global", int(record["revision"]), payload.actor_id, int(record["updated_at"]))
+ return {"values": record["value"], "revision": record["revision"], "updated_at": record["updated_at"]}
+
+
+@app.get("/api/runtime/info")
+def runtime_info():
+ data_writable = os.access(DATA_DIR, os.W_OK) if os.path.exists(DATA_DIR) else os.access(os.path.dirname(DATA_DIR), os.W_OK)
+ return {
+ "version": current_app_version(),
+ "pid": os.getpid(),
+ "host": RUNTIME_OPTIONS.host,
+ "port": RUNTIME_OPTIONS.port,
+ "runtime_mode": RUNTIME_OPTIONS.mode,
+ "parent_pid": RUNTIME_OPTIONS.parent_pid,
+ "data_writable": bool(data_writable),
+ "paths": APP_PATHS.public_summary(),
+ "data_layout": {
+ "database": str(DATA_LAYOUT.database_file),
+ "media": str(DATA_LAYOUT.media),
+ "logs": str(DATA_LAYOUT.logs),
+ "cache": str(DATA_LAYOUT.cache),
+ },
+ "database": DATABASE.pragma_summary(),
+ "migration": {key: value for key, value in MIGRATION_REPORT.items() if key != "moves"},
+ "secret_migration": SECRET_MIGRATION_REPORT,
+ "maintenance": MAINTENANCE_REPORT,
+ }
+
+
+@app.post("/api/runtime/shutdown")
+def runtime_shutdown(request: Request):
+ supplied = request.headers.get("x-desktop-token", "")
+ if not RUNTIME_OPTIONS.desktop_token or not hmac.compare_digest(supplied, RUNTIME_OPTIONS.desktop_token):
+ raise HTTPException(status_code=401, detail="桌面控制令牌无效")
+ request_shutdown()
+ return {"ok": True}
+
def connectivity_probe(name: str, url: str, timeout: float = 5.0) -> Dict[str, Any]:
started = time.time()
item = {
@@ -1785,6 +2246,15 @@ def version_gt(a: str, b: str) -> bool:
def check_update():
"""服务端检测 GitHub 与 ModelScope 两个源的远端版本(走系统代理,避免浏览器跨域/被墙)。"""
current = current_app_version()
+ if RUNTIME_OPTIONS.mode == "desktop":
+ return {
+ "current": current,
+ "latest": {},
+ "update_available": False,
+ "reachable": False,
+ "disabled": True,
+ "message": "便携版首版不提供自动更新;请退出软件后替换 Canvas.exe 与 app 目录。",
+ }
# 并发检测两个源,避免串行 8s+8s 拖慢首屏更新提示
holder: Dict[str, Dict[str, Any]] = {}
def _probe(key: str, url: str):
@@ -1978,13 +2448,13 @@ def schedule_self_restart(delay_seconds: int = 3) -> bool:
"cd /d \"%APP_DIR%\"\r\n"
"if exist \"%LAUNCHER%\" (\r\n"
" echo [%date% %time%] starting launcher: %LAUNCHER% >> \"%LOG_FILE%\"\r\n"
- " start \"ComfyUI-API-Modelscope\" /D \"%APP_DIR%\" cmd /k call \"%LAUNCHER%\"\r\n"
+ " start \"Canvas\" /D \"%APP_DIR%\" cmd /k call \"%LAUNCHER%\"\r\n"
") else (\r\n"
" echo [%date% %time%] launcher missing, fallback to python main.py >> \"%LOG_FILE%\"\r\n"
" if exist \"%APP_DIR%\\python\\python.exe\" (\r\n"
- " start \"ComfyUI-API-Modelscope\" /D \"%APP_DIR%\" cmd /k \"\"%APP_DIR%\\python\\python.exe\" main.py\"\r\n"
+ " start \"Canvas\" /D \"%APP_DIR%\" cmd /k \"\"%APP_DIR%\\python\\python.exe\" main.py\"\r\n"
" ) else (\r\n"
- " start \"ComfyUI-API-Modelscope\" /D \"%APP_DIR%\" cmd /k python main.py\r\n"
+ " start \"Canvas\" /D \"%APP_DIR%\" cmd /k python main.py\r\n"
" )\r\n"
")\r\n"
"del \"%~f0\"\r\n"
@@ -2097,6 +2567,8 @@ def stage_update_from_source(source: str, staging_root: str) -> Tuple[List[str],
@app.post("/api/update-from-github")
def update_from_github(req: UpdateRequest = UpdateRequest()):
+ if RUNTIME_OPTIONS.mode == "desktop":
+ raise HTTPException(status_code=409, detail="便携版禁止覆盖程序资源;请退出后替换 Canvas.exe 与 app 目录")
if not UPDATE_LOCK.acquire(blocking=False):
raise HTTPException(status_code=409, detail="正在更新中,请稍后再试")
staging_root = ""
@@ -2269,6 +2741,8 @@ class RollbackRequest(BaseModel):
@app.post("/api/update-rollback")
def rollback_update(req: RollbackRequest):
+ if RUNTIME_OPTIONS.mode == "desktop":
+ raise HTTPException(status_code=409, detail="便携版不使用源码更新回滚")
if not req.name:
raise HTTPException(status_code=400, detail="缺少备份名称")
if not UPDATE_LOCK.acquire(blocking=False):
@@ -2335,41 +2809,21 @@ def rollback_update(req: RollbackRequest):
finally:
UPDATE_LOCK.release()
-class GenerateRequest(BaseModel):
- prompt: str = ""
- width: int = 1024
- height: int = 1024
- workflow_json: str = "Z-Image.json"
- params: Dict[str, Any] = {}
- type: str = "zimage"
- client_id: str = ""
- convert_to_jpg: bool = False
-
class DeleteHistoryRequest(BaseModel):
timestamp: float
class TokenRequest(BaseModel):
token: str
-class CloudGenRequest(BaseModel):
- prompt: str
- api_key: str = ""
- model: str = ""
- resolution: str = "1024x1024"
- type: str = "zimage"
- image_urls: List[str] = []
- loras: Optional[Any] = None
- client_id: Optional[str] = None
-
-class CloudPollRequest(BaseModel):
- task_id: str
- api_key: str = ""
- client_id: Optional[str] = None
class AIReference(BaseModel):
url: str = ""
name: str = ""
role: str = ""
+ reference_id: str = ""
+ reference_type: str = ""
+ label: str = ""
+ instruction: str = ""
kind: str = ""
mime: str = ""
@@ -2382,12 +2836,50 @@ class OnlineImageRequest(BaseModel):
n: int = 1
reference_images: List[AIReference] = []
+class EcommerceTaskRequest(BaseModel):
+ operation: str
+ mode: str = "standard"
+ inputs: List[AIReference] = Field(default_factory=list)
+ options: Dict[str, Any] = Field(default_factory=dict)
+ provider_id: str = ""
+ model: str = ""
+ aspect_ratio: str = "source"
+ resolution: str = "auto"
+ quality: str = "auto"
+ count: int = Field(default=0, ge=0, le=4)
+ parent_task_id: str = ""
+
+class EcommerceTaskStatusRequest(BaseModel):
+ ids: List[str] = Field(default_factory=list, max_length=2000)
+
+class EcommerceApprovalRequest(BaseModel):
+ output_index: int = 0
+ checks: Dict[str, bool] = Field(default_factory=dict)
+ note: str = Field(default="", max_length=1000)
+
+class WorkFavoriteRequest(BaseModel):
+ favorite: bool
+
+class WorkMetadataRequest(BaseModel):
+ name: Optional[str] = Field(default=None, max_length=160)
+ favorite: Optional[bool] = None
+ trashed: Optional[bool] = None
+
class ImageTaskQueryRequest(BaseModel):
provider_id: str = "comfly"
task_id: str = Field(min_length=1, max_length=240)
CANVAS_TASKS: Dict[str, Dict[str, Any]] = {}
CANVAS_TASK_LOCK = Lock()
+ONLINE_IMAGE_TASKS: Dict[str, Dict[str, Any]] = {}
+ECOMMERCE_TASKS: Dict[str, Dict[str, Any]] = {}
+ECOMMERCE_MAX_CONCURRENCY = max(1, min(2000, int(os.getenv("ECOMMERCE_MAX_CONCURRENCY", "2000") or 2000)))
+ECOMMERCE_VISION_MAX_CONCURRENCY = max(1, min(256, int(os.getenv("ECOMMERCE_VISION_MAX_CONCURRENCY", "32") or 32)))
+ECOMMERCE_TASK_SEMAPHORE = asyncio.Semaphore(ECOMMERCE_MAX_CONCURRENCY)
+ECOMMERCE_VISION_SEMAPHORE = asyncio.Semaphore(ECOMMERCE_VISION_MAX_CONCURRENCY)
+ECOMMERCE_VISION_CACHE: Dict[str, Dict[str, Any]] = {}
+ECOMMERCE_VISION_CACHE_LOCK = Lock()
+ECOMMERCE_VISION_CACHE_LIMIT = 5000
class CanvasVideoRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=VIDEO_PROMPT_MAX_LENGTH)
@@ -2436,6 +2928,9 @@ class RunningHubUploadAssetRequest(BaseModel):
class JimengHelpRequest(BaseModel):
command: str = ""
+class CodexHelpRequest(BaseModel):
+ command: str = ""
+
class JimengQueryMediaRequest(BaseModel):
submit_id: str = ""
kind: str = "image"
@@ -2515,17 +3010,6 @@ def chat_system_prompt(payload):
prompt = str(getattr(payload, "system_prompt", "") or "").strip()
return prompt or SYSTEM_PROMPT
-class MsGenerateRequest(BaseModel):
- prompt: str
- api_key: str = ""
- model: str = "black-forest-labs/FLUX.2-klein-9B"
- image_urls: List[str] = []
- width: int = 0
- height: int = 0
- size: str = ""
- loras: Optional[Any] = None
- client_id: Optional[str] = None
-
class CanvasLLMRequest(BaseModel):
message: str = Field(min_length=1, max_length=LLM_MESSAGE_MAX_LENGTH)
system_prompt: str = ""
@@ -2574,6 +3058,7 @@ class CanvasSaveRequest(BaseModel):
settings: Dict[str, Any] = {}
client_id: str = ""
base_updated_at: int = 0
+ base_revision: int = 0
class CanvasAssetCheckRequest(BaseModel):
urls: List[str] = []
@@ -2734,289 +3219,47 @@ class PromptLibraryCategoryRequest(BaseModel):
# --- 负载均衡 ---
-def check_images_exist(backend_addr, images):
- if not images: return True
- for img in images:
- try:
- url = f"http://{backend_addr}/view?filename={urllib.parse.quote(img)}&type=input"
- r = requests.get(url, stream=True, timeout=0.5)
- r.close()
- if r.status_code != 200: return False
- except: return False
- return True
-
-MEDIA_INPUT_KEYS = ("image", "video", "audio", "mask", "filename", "file")
-MEDIA_INPUT_EXT_RE = re.compile(r"\.(png|jpe?g|webp|gif|bmp|tiff?|mp4|webm|mov|m4v|avi|mkv|mp3|wav|m4a|aac|ogg|flac)(?:\?|$)", re.I)
-
-def is_comfy_input_media_value(input_name: str, value: Any) -> bool:
- if not isinstance(value, str) or not value.strip():
- return False
- key = str(input_name or "").lower()
- if any(token in key for token in MEDIA_INPUT_KEYS):
- return True
- return bool(MEDIA_INPUT_EXT_RE.search(value))
+def save_to_history(record):
+ with HISTORY_LOCK:
+ if "timestamp" not in record:
+ record["timestamp"] = time.time()
+ DATABASE.prepend_history(record, limit=5000)
+ publish_entity_changed("history", "global")
-def collect_required_comfy_media(params: Dict[str, Any]) -> List[str]:
- required = []
- for node_inputs in (params or {}).values():
- if not isinstance(node_inputs, dict):
- continue
- for input_name, value in node_inputs.items():
- if is_comfy_input_media_value(input_name, value):
- required.append(value)
- return list(dict.fromkeys(required))
+def safe_user_id(user_id, request: Request):
+ candidate = (user_id or "").strip()
+ if not candidate and request.client:
+ candidate = f"ip-{request.client.host}"
+ if not candidate:
+ candidate = "anonymous"
+ candidate = re.sub(r"[^a-zA-Z0-9_.-]", "-", candidate)[:80].strip(".-")
+ return candidate or "anonymous"
-def get_best_backend(required_images: List[str] = None):
- best_backend = COMFYUI_INSTANCES[0]
- min_queue_size = float('inf')
- backend_stats = {}
+def user_dir(user_id):
+ return str(user_id or "anonymous")
- for addr in COMFYUI_INSTANCES:
- try:
- with urllib.request.urlopen(f"http://{addr}/queue", timeout=1) as response:
- data = json.loads(response.read())
- remote_load = len(data.get('queue_running', [])) + len(data.get('queue_pending', []))
- with LOAD_LOCK:
- local_load = BACKEND_LOCAL_LOAD.get(addr, 0)
- effective_load = max(remote_load, local_load)
- has_images = check_images_exist(addr, required_images)
- backend_stats[addr] = {"load": effective_load, "has_images": has_images}
- except Exception as e:
- print(f"Backend {addr} unreachable: {e}")
- continue
+def conversation_path(user_id, conversation_id):
+ cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", conversation_id or "")
+ if not cleaned:
+ raise HTTPException(status_code=400, detail="无效的对话 ID")
+ return cleaned
- if not backend_stats:
- return COMFYUI_INSTANCES[0]
+def now_ms():
+ return int(time.time() * 1000)
- for addr, stats in backend_stats.items():
- load = stats["load"]
- if load < min_queue_size or (load == min_queue_size and stats.get("has_images") and not backend_stats.get(best_backend, {}).get("has_images")):
- min_queue_size = load
- best_backend = addr
+def publish_entity_changed(topic: str, entity_id: str = "global", revision: int = 0, actor_id: str = "", updated_at: int = 0):
+ event_revision = int(revision or DATABASE.next_revision(topic, entity_id))
+ if GLOBAL_LOOP and not GLOBAL_LOOP.is_closed():
+ asyncio.run_coroutine_threadsafe(
+ manager.broadcast_entity_changed(topic, entity_id, event_revision, actor_id, updated_at or now_ms()),
+ GLOBAL_LOOP,
+ )
+ return event_revision
- return best_backend
-
-def reserve_best_backend(required_images: List[str] = None):
- backend_stats = {}
- for addr in COMFYUI_INSTANCES:
- try:
- with urllib.request.urlopen(f"http://{addr}/queue", timeout=1) as response:
- data = json.loads(response.read())
- remote_load = len(data.get('queue_running', [])) + len(data.get('queue_pending', []))
- has_images = check_images_exist(addr, required_images)
- backend_stats[addr] = {"remote_load": remote_load, "has_images": has_images}
- except Exception as e:
- print(f"Backend {addr} unreachable: {e}")
- continue
- with LOAD_LOCK:
- best_backend = COMFYUI_INSTANCES[0]
- min_load = float('inf')
- if backend_stats:
- for addr, stats in backend_stats.items():
- load = max(stats["remote_load"], BACKEND_LOCAL_LOAD.get(addr, 0))
- if load < min_load or (load == min_load and stats.get("has_images") and not backend_stats.get(best_backend, {}).get("has_images")):
- min_load = load
- best_backend = addr
- BACKEND_LOCAL_LOAD[best_backend] = BACKEND_LOCAL_LOAD.get(best_backend, 0) + 1
- return best_backend
-
-# --- 辅助工具 ---
-
-def download_image(comfy_address, comfy_url_path, prefix="studio_"):
- filename = f"{prefix}{uuid.uuid4().hex[:10]}.png"
- local_path = output_path_for(filename, "output")
- full_url = f"http://{comfy_address}{comfy_url_path}"
- try:
- with urllib.request.urlopen(full_url, timeout=COMFYUI_DOWNLOAD_TIMEOUT) as response, open(local_path, 'wb') as out_file:
- shutil.copyfileobj(response, out_file)
- return output_url_for(filename, "output")
- except Exception as e:
- print(f"下载图片失败: {e}")
- if comfy_url_path.startswith("/view"):
- return comfy_url_path.replace("/view", "/api/view", 1)
- return full_url
-
-def comfy_output_extension(item):
- filename = str((item or {}).get("filename") or "")
- ext = os.path.splitext(filename)[1].lower()
- if ext in {
- ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff",
- ".mp4", ".webm", ".mov", ".m4v", ".avi", ".mkv",
- ".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac",
- ".txt", ".json", ".csv", ".srt", ".vtt", ".md",
- }:
- return ext
- fmt = str((item or {}).get("format") or "").lower()
- if "mpeg" in fmt or "mp3" in fmt:
- return ".mp3"
- if "wav" in fmt or "wave" in fmt:
- return ".wav"
- if "ogg" in fmt:
- return ".ogg"
- if "flac" in fmt:
- return ".flac"
- if "text" in fmt or "plain" in fmt:
- return ".txt"
- if "json" in fmt:
- return ".json"
- if "webm" in fmt:
- return ".webm"
- if "quicktime" in fmt or "mov" in fmt:
- return ".mov"
- if "mp4" in fmt or "h264" in fmt or "video" in fmt:
- return ".mp4"
- return ext or ".bin"
-
-def is_video_output_item(item):
- ext = comfy_output_extension(item)
- fmt = str((item or {}).get("format") or "").lower()
- return ext in {".mp4", ".webm", ".mov", ".m4v", ".avi", ".mkv"} or "video" in fmt
-
-def comfy_output_kind(item):
- ext = comfy_output_extension(item)
- fmt = str((item or {}).get("format") or "").lower()
- if ext in {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"} or "image" in fmt:
- return "image"
- if ext in {".mp4", ".webm", ".mov", ".m4v", ".avi", ".mkv"} or "video" in fmt:
- return "video"
- if ext in {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac"} or "audio" in fmt or "sound" in fmt:
- return "audio"
- if ext in {".txt", ".json", ".csv", ".srt", ".vtt", ".md"} or "text" in fmt or "json" in fmt:
- return "text"
- return "file"
-
-def download_comfy_output(comfy_address, item, prefix="studio_"):
- ext = comfy_output_extension(item)
- filename = f"{prefix}{uuid.uuid4().hex[:10]}{ext}"
- local_path = output_path_for(filename, "output")
- subfolder = urllib.parse.quote(str(item.get("subfolder") or ""))
- file_type = urllib.parse.quote(str(item.get("type") or "output"))
- comfy_url_path = f"/view?filename={urllib.parse.quote(str(item['filename']))}&subfolder={subfolder}&type={file_type}"
- full_url = f"http://{comfy_address}{comfy_url_path}"
- try:
- with urllib.request.urlopen(full_url, timeout=COMFYUI_DOWNLOAD_TIMEOUT) as response, open(local_path, 'wb') as out_file:
- shutil.copyfileobj(response, out_file)
- return output_url_for(filename, "output")
- except Exception as e:
- print(f"下载 ComfyUI 输出失败: {e}")
- if comfy_url_path.startswith("/view"):
- return comfy_url_path.replace("/view", "/api/view", 1)
- return full_url
-
-def save_comfy_text_output(value, prefix="studio_", name=""):
- text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2)
- stem = sanitize_export_filename(name or "comfy_text.txt", "comfy_text.txt")
- _, ext = os.path.splitext(stem)
- if ext.lower() not in {".txt", ".json", ".csv", ".srt", ".vtt", ".md"}:
- stem += ".txt"
- filename = f"{prefix}{uuid.uuid4().hex[:10]}_{stem}"
- path = output_path_for(filename, "output")
- with open(path, "w", encoding="utf-8") as f:
- f.write(text)
- return output_url_for(filename, "output")
-
-def comfy_text_values_from_output(node_output):
- values = []
- text_keys = ("text", "texts", "prompt", "prompts", "string", "strings", "caption", "captions")
- for key in text_keys:
- if key not in node_output:
- continue
- value = node_output.get(key)
- items = value if isinstance(value, list) else [value]
- for item in items:
- if isinstance(item, dict):
- text = item.get("text") or item.get("prompt") or item.get("caption") or item.get("value")
- name = item.get("filename") or item.get("name") or f"{key}.txt"
- else:
- text = item
- name = f"{key}.txt"
- if text is None:
- continue
- text = str(text)
- if text.strip():
- values.append((text, name))
- return values
-
-def collect_comfy_file_items(node_output):
- items = []
- for key, value in (node_output or {}).items():
- if key in {"text", "texts", "prompt", "prompts", "string", "strings", "caption", "captions"}:
- continue
- candidates = value if isinstance(value, list) else [value]
- for item in candidates:
- if isinstance(item, dict) and item.get("filename"):
- items.append((key, item))
- return items
-
-# 纯预览/对比类节点:其输出只用于界面展示(PreviewImage、rgthree 的 Image Comparer 等),
-# 工作流里通常还有 SaveImage 产出真正结果,故有正式产出时应丢弃这些冗余预览/对比图。
-COMFY_PREVIEW_CLASS_HINTS = ("previewimage", "comparer", "imagecompare", "image compare")
-# show/utility 类调试文本节点:ShowText、各种 *Anything、CR Text、MathExpression、note 等,
-# 它们的 ui 文本基本是调试信息,不应混进最终结果。
-COMFY_DEBUG_TEXT_CLASS_HINTS = (
- "showtext", "show text", "showanything", "show any", "preview any", "previewany",
- "displaytext", "display text", "display any", "anything everywhere", "convertanything",
- "easy show", "note", "mathexpression", "cr text", "text multiline", "string function",
- "debug",
-)
-
-def comfy_class_is_preview(class_type):
- ct = str(class_type or "").lower()
- return bool(ct) and any(h in ct for h in COMFY_PREVIEW_CLASS_HINTS)
-
-def comfy_class_is_debug_text(class_type):
- ct = str(class_type or "").lower()
- return bool(ct) and any(h in ct for h in COMFY_DEBUG_TEXT_CLASS_HINTS)
-
-def save_to_history(record):
- with HISTORY_LOCK:
- history = []
- if os.path.exists(HISTORY_FILE):
- try:
- with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
- history = json.load(f)
- except: pass
- if "timestamp" not in record:
- record["timestamp"] = time.time()
- history.insert(0, record)
- with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
- json.dump(history[:5000], f, ensure_ascii=False, indent=4)
-
-def get_comfy_history(comfy_address, prompt_id):
- try:
- with urllib.request.urlopen(f"http://{comfy_address}/history/{prompt_id}") as response:
- return json.loads(response.read())
- except Exception as e:
- return {}
-
-def safe_user_id(user_id, request: Request):
- candidate = (user_id or "").strip()
- if not candidate and request.client:
- candidate = f"ip-{request.client.host}"
- if not candidate:
- candidate = "anonymous"
- candidate = re.sub(r"[^a-zA-Z0-9_.-]", "-", candidate)[:80].strip(".-")
- return candidate or "anonymous"
-
-def user_dir(user_id):
- path = os.path.join(CONVERSATION_DIR, user_id)
- os.makedirs(path, exist_ok=True)
- return path
-
-def conversation_path(user_id, conversation_id):
- cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", conversation_id or "")
- if not cleaned:
- raise HTTPException(status_code=400, detail="无效的对话 ID")
- return os.path.join(user_dir(user_id), f"{cleaned}.json")
-
-def now_ms():
- return int(time.time() * 1000)
-
-def save_conversation(user_id, conversation):
- with CONVERSATION_LOCK:
- path = conversation_path(user_id, conversation["id"])
- with open(path, 'w', encoding='utf-8') as f:
- json.dump(conversation, f, ensure_ascii=False, indent=2)
+def save_conversation(user_id, conversation, actor_id=""):
+ with CONVERSATION_LOCK:
+ DATABASE.save_conversation(user_id, conversation)
+ publish_entity_changed("session", conversation.get("id") or user_id, actor_id=actor_id)
def new_conversation(user_id, title="新对话"):
timestamp = now_ms()
@@ -3031,23 +3274,15 @@ def new_conversation(user_id, title="新对话"):
return conversation
def load_conversation(user_id, conversation_id):
- path = conversation_path(user_id, conversation_id)
- if not os.path.exists(path):
+ cleaned = conversation_path(user_id, conversation_id)
+ conversation = DATABASE.get_conversation(user_id, cleaned)
+ if not conversation:
raise HTTPException(status_code=404, detail="对话不存在")
- with open(path, 'r', encoding='utf-8') as f:
- return json.load(f)
+ return conversation
def list_conversations(user_id):
records = []
- for filename in os.listdir(user_dir(user_id)):
- if not filename.endswith(".json"):
- continue
- path = os.path.join(user_dir(user_id), filename)
- try:
- with open(path, 'r', encoding='utf-8') as f:
- data = json.load(f)
- except Exception:
- continue
+ for data in DATABASE.list_conversations(user_id):
messages = data.get("messages", [])
last_message = next((m for m in reversed(messages) if m.get("role") != "system"), None)
records.append({
@@ -3063,36 +3298,28 @@ def canvas_path(canvas_id):
cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", canvas_id or "")
if not cleaned:
raise HTTPException(status_code=400, detail="无效的画布 ID")
- return os.path.join(CANVAS_DIR, f"{cleaned}.json")
+ return cleaned
-def save_canvas(canvas):
+def save_canvas(canvas, actor_id=""):
canvas["updated_at"] = now_ms()
with CANVAS_LOCK:
- with open(canvas_path(canvas["id"]), 'w', encoding='utf-8') as f:
- json.dump(canvas, f, ensure_ascii=False, indent=2)
+ canvas_path(canvas["id"])
+ DATABASE.save_canvas(canvas, touch=False)
+ publish_entity_changed("canvas", canvas["id"], int(canvas.get("revision") or 0), actor_id, int(canvas.get("updated_at") or 0))
def normalize_canvas_kind(kind="classic"):
return "smart" if str(kind or "").strip().lower() == "smart" else "classic"
# ===== 项目(按项目分类管理画布)=====
-PROJECTS_PATH = os.path.join(DATA_DIR, "projects.json")
DEFAULT_PROJECT_ID = "default"
def load_projects():
- try:
- with open(PROJECTS_PATH, 'r', encoding='utf-8') as f:
- data = json.load(f)
- projects = data.get("projects") if isinstance(data, dict) else data
- if isinstance(projects, list):
- return [p for p in projects if isinstance(p, dict) and p.get("id")]
- except Exception:
- pass
- return []
+ return [p for p in DATABASE.load_projects() if isinstance(p, dict) and p.get("id")]
def save_projects(projects):
with CANVAS_LOCK:
- with open(PROJECTS_PATH, 'w', encoding='utf-8') as f:
- json.dump({"projects": projects}, f, ensure_ascii=False, indent=2)
+ DATABASE.save_projects(projects)
+ publish_entity_changed("project", "global")
def project_record(p):
return {
@@ -3164,21 +3391,20 @@ def new_canvas(title="未命名画布", icon="layers", kind="classic", project=N
return canvas
def load_canvas(canvas_id):
- path = canvas_path(canvas_id)
- if not os.path.exists(path):
+ cleaned = canvas_path(canvas_id)
+ canvas = DATABASE.get_canvas(cleaned)
+ if not canvas:
raise HTTPException(status_code=404, detail="画布不存在")
- with open(path, 'r', encoding='utf-8') as f:
- canvas = json.load(f)
if canvas.get("deleted_at"):
raise HTTPException(status_code=404, detail="画布已在回收站")
return canvas
def load_canvas_any(canvas_id):
- path = canvas_path(canvas_id)
- if not os.path.exists(path):
+ cleaned = canvas_path(canvas_id)
+ canvas = DATABASE.get_canvas(cleaned)
+ if not canvas:
raise HTTPException(status_code=404, detail="画布不存在")
- with open(path, 'r', encoding='utf-8') as f:
- return json.load(f)
+ return canvas
CANVAS_COLORS = {"", "red", "orange", "amber", "green", "teal", "blue", "violet", "pink", "slate"}
@@ -3207,33 +3433,15 @@ def canvas_record(data):
def cleanup_expired_canvas_trash():
cutoff = now_ms() - CANVAS_TRASH_RETENTION_MS
with CANVAS_LOCK:
- for filename in os.listdir(CANVAS_DIR):
- if not filename.endswith(".json"):
- continue
- path = os.path.join(CANVAS_DIR, filename)
- try:
- with open(path, 'r', encoding='utf-8') as f:
- data = json.load(f)
- deleted_at = int(data.get("deleted_at") or 0)
- if deleted_at and deleted_at < cutoff:
- os.remove(path)
- except Exception:
- continue
+ for data in DATABASE.list_canvases(include_deleted=True):
+ deleted_at = int(data.get("deleted_at") or 0)
+ if deleted_at and deleted_at < cutoff:
+ DATABASE.purge_canvas(str(data.get("id") or ""))
def iter_canvas_records(include_deleted=False):
cleanup_expired_canvas_trash()
records = []
- for filename in os.listdir(CANVAS_DIR):
- if not filename.endswith(".json"):
- continue
- try:
- with open(os.path.join(CANVAS_DIR, filename), 'r', encoding='utf-8') as f:
- data = json.load(f)
- except Exception:
- continue
- is_deleted = bool(data.get("deleted_at"))
- if include_deleted != is_deleted:
- continue
+ for data in DATABASE.list_canvases(include_deleted=bool(include_deleted)):
records.append(canvas_record(data))
return records
@@ -3361,16 +3569,7 @@ def canvas_assets_index():
canvas_counts = {"all": 0, "smart": 0, "classic": 0}
item_counts = {"all": 0, "smart": 0, "classic": 0}
cleanup_expired_canvas_trash()
- for filename in os.listdir(CANVAS_DIR):
- if not filename.endswith(".json"):
- continue
- try:
- with open(os.path.join(CANVAS_DIR, filename), "r", encoding="utf-8") as f:
- canvas = json.load(f)
- except Exception:
- continue
- if canvas.get("deleted_at"):
- continue
+ for canvas in DATABASE.list_canvases(include_deleted=False):
record = canvas_record(canvas)
canvas_items = extract_canvas_assets(canvas)
record["asset_count"] = len(canvas_items)
@@ -3484,7 +3683,7 @@ def looks_like_vision_chat_model(model):
if not lc:
return False
vision_keys = [
- "vision", "vl-", "-vl-", "internvl", "qvq", "qwen-vl",
+ "vision", "vl-", "-vl-", "vlm", "internvl", "qvq", "qwen-vl",
"doubao-vision", "glm-4v", "minicpm-v",
]
return any(key in lc for key in vision_keys)
@@ -3779,7 +3978,7 @@ def provider_protocol(provider):
# 单模型可覆盖的协议(仅 OpenAI / Gemini,二者可共用同一站点的 Base URL + Key)
PER_MODEL_PROTOCOL_OPTIONS = {"openai", "gemini"}
# 协议固定、不支持单模型覆盖的内置平台
-FIXED_PROTOCOL_PROVIDER_IDS = {"modelscope", "volcengine", "jimeng", "runninghub"}
+FIXED_PROTOCOL_PROVIDER_IDS = {"modelscope", "volcengine", "jimeng", "runninghub", "grsai", "codex", "local-vision"}
def normalize_model_protocols(value):
"""规整 {模型名: 协议} 覆盖表,仅保留 openai/gemini。"""
@@ -3833,9 +4032,291 @@ def is_volcengine_provider(provider):
def is_runninghub_provider(provider):
return provider_protocol(provider) == "runninghub" or str((provider or {}).get("id") or "").strip().lower() == "runninghub"
+def is_grsai_provider(provider):
+ provider_id = str((provider or {}).get("id") or "").strip().lower()
+ base_url = str((provider or {}).get("base_url") or "").strip().lower()
+ return provider_id == "grsai" or "grsaiapi.com" in base_url or "grsai.dakka.com.cn" in base_url
+
+def is_grsai_nano_model(model):
+ return str(model or "").strip().lower().startswith("nano-banana")
+
def is_jimeng_provider(provider):
return provider_protocol(provider) == "jimeng" or str((provider or {}).get("id") or "").strip().lower() == "jimeng"
+def is_codex_provider(provider):
+ return provider_protocol(provider) == "codex" or str((provider or {}).get("id") or "").strip().lower() == "codex"
+
+def codex_env_value(key):
+ return os.getenv(key, "") or read_api_env_value(key)
+
+def codex_cli_executable():
+ configured = str(codex_env_value("CODEX_BIN") or "").strip()
+ if configured:
+ return configured
+ return shutil.which("codex") or shutil.which("codex.exe") or shutil.which("codex.cmd") or ""
+
+def codex_timeout(default=CODEX_DEFAULT_TIMEOUT):
+ try:
+ return max(30, min(3600, int(os.getenv("CODEX_CLI_TIMEOUT", str(default)) or default)))
+ except Exception:
+ return default
+
+def codex_model_for_exec(model="", fallback=""):
+ value = str(model or fallback or "").strip()
+ low = value.lower()
+ if not value or low.startswith("$imagegen") or low.startswith("gpt-image"):
+ return ""
+ return value
+
+def codex_decode_output(stdout, stderr):
+ out_text = (stdout or b"").decode("utf-8", errors="replace").strip()
+ err_text = (stderr or b"").decode("utf-8", errors="replace").strip()
+ return out_text, err_text
+
+async def run_codex_cli(prompt, model="", image_paths=None, timeout=None, output_last_message=True):
+ exe = codex_cli_executable()
+ if not exe:
+ raise HTTPException(status_code=400, detail="未找到 OpenAI Codex CLI。请先运行 CLI/windows/openai/1-install_openai_codex_cli.bat,并完成 codex 登录。")
+ image_paths = [str(path) for path in (image_paths or []) if path and os.path.isfile(str(path))]
+ last_path = ""
+ args = [
+ exe,
+ "exec",
+ "--cd",
+ BASE_DIR,
+ "--sandbox",
+ "workspace-write",
+ "--skip-git-repo-check",
+ ]
+ exec_model = codex_model_for_exec(model)
+ if exec_model:
+ args.extend(["--model", exec_model])
+ for path in image_paths:
+ args.extend(["--image", path])
+ if output_last_message:
+ fd, last_path = tempfile.mkstemp(prefix="codex_last_", suffix=".txt", dir=OUTPUT_OUTPUT_DIR)
+ os.close(fd)
+ args.extend(["--output-last-message", last_path])
+ args.append("-")
+ prompt_bytes = str(prompt or "").encode("utf-8")
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *args,
+ cwd=BASE_DIR,
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(proc.communicate(input=prompt_bytes), timeout=timeout or codex_timeout())
+ except asyncio.TimeoutError as exc:
+ raise HTTPException(status_code=504, detail="OpenAI Codex CLI 执行超时。可设置 CODEX_CLI_TIMEOUT 增大等待时间。") from exc
+ except FileNotFoundError as exc:
+ raise HTTPException(status_code=400, detail=f"未找到 OpenAI Codex CLI:{exe}") from exc
+ out_text, err_text = codex_decode_output(stdout, stderr)
+ last_text = ""
+ if last_path and os.path.exists(last_path):
+ try:
+ with open(last_path, "r", encoding="utf-8-sig") as f:
+ last_text = f.read().strip()
+ except Exception:
+ last_text = ""
+ try:
+ os.remove(last_path)
+ except Exception:
+ pass
+ if proc.returncode != 0:
+ message = err_text or out_text or last_text or f"exit={proc.returncode}"
+ raise HTTPException(status_code=502, detail=f"OpenAI Codex CLI 调用失败:{message[:1200]}")
+ return {"text": last_text or out_text, "_stdout": out_text, "_stderr": err_text}
+
+def codex_output_image_files(since_time=0):
+ exts = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
+ root = os.path.abspath(OUTPUT_OUTPUT_DIR)
+ files = []
+ try:
+ for name in os.listdir(root):
+ path = os.path.join(root, name)
+ if not os.path.isfile(path):
+ continue
+ ext = os.path.splitext(name)[1].lower()
+ if ext not in exts:
+ continue
+ mtime = os.path.getmtime(path)
+ if mtime + 1 < float(since_time or 0):
+ continue
+ files.append((mtime, path))
+ except Exception:
+ return []
+ return [path for _mtime, path in sorted(files, reverse=True)]
+
+def codex_output_url_from_path(path):
+ path = os.path.abspath(str(path or ""))
+ root = os.path.abspath(OUTPUT_OUTPUT_DIR)
+ try:
+ if os.path.commonpath([root, path]) == root:
+ return output_url_for(os.path.basename(path), "output")
+ except Exception:
+ return ""
+ return ""
+
+async def codex_prepare_local_media(ref_url):
+ text = str(ref_url or "").strip()
+ if not text:
+ return "", []
+ if text.startswith(("/output/", "/assets/")):
+ path = output_file_from_url(text)
+ if path:
+ return path, []
+ raise HTTPException(status_code=404, detail=f"OpenAI CLI 参考素材不存在:{text}")
+ if text.startswith("file://"):
+ path = urllib.parse.unquote(urllib.parse.urlparse(text).path)
+ if os.name == "nt" and re.match(r"^/[A-Za-z]:/", path):
+ path = path[1:]
+ if os.path.isfile(path):
+ return path, []
+ if os.path.isfile(text):
+ return text, []
+ temp_paths = []
+ suffix = ".png"
+ if text.startswith("data:"):
+ if ";base64," not in text:
+ raise HTTPException(status_code=400, detail="OpenAI CLI 参考素材 data URL 缺少 base64 数据")
+ header, encoded = text.split(";base64,", 1)
+ mime = header.split(":", 1)[1].split(";", 1)[0] if ":" in header else ""
+ suffix = mimetypes.guess_extension(mime) or suffix
+ fd, path = tempfile.mkstemp(prefix="codex_ref_", suffix=suffix)
+ with os.fdopen(fd, "wb") as f:
+ f.write(base64.b64decode(encoded))
+ temp_paths.append(path)
+ return path, temp_paths
+ if text.startswith(("http://", "https://")):
+ async with httpx.AsyncClient(timeout=httpx.Timeout(connect=20.0, read=300.0, write=60.0, pool=20.0), follow_redirects=True) as client:
+ response = await client.get(text)
+ response.raise_for_status()
+ clean_path = urllib.parse.urlparse(text).path
+ suffix = os.path.splitext(clean_path)[1] or mimetypes.guess_extension(response.headers.get("content-type", "")) or suffix
+ fd, path = tempfile.mkstemp(prefix="codex_ref_", suffix=suffix)
+ with os.fdopen(fd, "wb") as f:
+ f.write(response.content)
+ temp_paths.append(path)
+ return path, temp_paths
+ raise HTTPException(status_code=400, detail=f"OpenAI CLI 无法读取参考素材:{text[:120]}")
+
+async def codex_reference_paths(reference_images=None):
+ paths = []
+ temp_paths = []
+ try:
+ for ref in (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX]:
+ url = ref.get("url") if isinstance(ref, dict) else getattr(ref, "url", "")
+ if not url:
+ continue
+ path, created = await codex_prepare_local_media(url)
+ if path:
+ paths.append(path)
+ temp_paths.extend(created)
+ return paths, temp_paths
+ except Exception:
+ for path in temp_paths:
+ try:
+ os.remove(path)
+ except Exception:
+ pass
+ raise
+
+def codex_models_payload(raw=None):
+ all_models = [*CODEX_DEFAULT_IMAGE_MODELS, *CODEX_DEFAULT_CHAT_MODELS]
+ return {
+ "ok": True,
+ "protocol": "codex",
+ "status": 200,
+ "message": "OpenAI Codex CLI 可用,模型列表来自本机 CLI 默认配置。",
+ "model_count": len(all_models),
+ "total": len(all_models),
+ "image_models": CODEX_DEFAULT_IMAGE_MODELS,
+ "chat_models": CODEX_DEFAULT_CHAT_MODELS,
+ "video_models": [],
+ "all": all_models,
+ "raw": raw or {},
+ }
+
+async def generate_codex_provider_image(prompt, size, model, reference_images=None, provider=None):
+ ref_paths, temp_paths = await codex_reference_paths(reference_images)
+ since = time.time()
+ try:
+ image_prompt = (
+ "$imagegen\n\n"
+ f"任务:{prompt}\n\n"
+ f"尺寸/比例参考:{size or 'auto'}。\n"
+ f"请生成或编辑图片,并把最终图片文件保存到这个本地目录:{OUTPUT_OUTPUT_DIR}\n"
+ "只需要输出最终文件路径和一句简短说明;不要修改项目代码,不要创建额外文档。"
+ )
+ raw = await run_codex_cli(image_prompt, model="", image_paths=ref_paths, timeout=codex_timeout(), output_last_message=True)
+ files = codex_output_image_files(since)
+ urls = []
+ for path in files:
+ url = codex_output_url_from_path(path)
+ if url and url not in urls:
+ urls.append(url)
+ if not urls:
+ text = f"{raw.get('text') or raw.get('_stdout') or ''}\n{raw.get('_stderr') or ''}"
+ pattern = r"([A-Za-z]:\\[^\r\n\"'<>]+\.(?:png|jpe?g|webp|gif)|/[^\r\n\"'<>]+\.(?:png|jpe?g|webp|gif))"
+ for match in re.findall(pattern, text, flags=re.I):
+ url = codex_output_url_from_path(match.strip())
+ if url and url not in urls:
+ urls.append(url)
+ if not urls:
+ status_text = (raw.get("text") or raw.get("_stdout") or raw.get("_stderr") or "")[:1200]
+ raise HTTPException(status_code=502, detail=f"OpenAI CLI 已返回,但没有在输出目录发现图片:{status_text}")
+ return {"type": "url", "value": urls[0]}, {"images": urls, "text": raw.get("text"), "provider": "codex"}
+ finally:
+ for path in temp_paths:
+ try:
+ os.remove(path)
+ except Exception:
+ pass
+
+def codex_chat_prompt(payload, history_messages=None):
+ parts = []
+ system_prompt = str(getattr(payload, "system_prompt", "") or "").strip()
+ if system_prompt:
+ parts.append(f"系统要求:\n{system_prompt}")
+ for item in (history_messages or [])[-MAX_HISTORY_MESSAGES:]:
+ role = str(item.get("role") or "").strip()
+ content = item.get("content")
+ if role in {"user", "assistant"} and content:
+ label = "用户" if role == "user" else "助手"
+ parts.append(f"{label}:\n{content}")
+ message = str(getattr(payload, "message", "") or "").strip()
+ parts.append(f"用户:\n{message}")
+ parts.append("请直接回答用户,输出纯文本,不要修改项目文件。")
+ return "\n\n".join(part for part in parts if part).strip()
+
+async def codex_chat_text(payload, history_messages=None):
+ image_paths = []
+ temp_paths = []
+ try:
+ image_values = []
+ if hasattr(payload, "images"):
+ image_values.extend([{"url": item} for item in (getattr(payload, "images", None) or []) if item])
+ if hasattr(payload, "reference_images"):
+ image_values.extend([ref.dict() for ref in (getattr(payload, "reference_images", None) or []) if getattr(ref, "url", "")])
+ image_paths, temp_paths = await codex_reference_paths(image_values)
+ raw = await run_codex_cli(
+ codex_chat_prompt(payload, history_messages),
+ model=getattr(payload, "model", "") or CODEX_DEFAULT_CHAT_MODELS[0],
+ image_paths=image_paths,
+ timeout=codex_timeout(),
+ output_last_message=True,
+ )
+ text = str(raw.get("text") or "").strip()
+ return text or "Codex CLI 返回了空回复。", raw
+ finally:
+ for path in temp_paths:
+ try:
+ os.remove(path)
+ except Exception:
+ pass
+
def is_yuli_provider(provider):
# 玉玉API(yuli.host)的视频接口走自有格式(/v1/video/create + /v1/video/query),
# 与通用 OpenAI /v1/videos/generations 不同,需单独识别。
@@ -4695,12 +5176,18 @@ def output_file_from_url(url):
if not url or not (url.startswith("/output/") or url.startswith("/assets/")):
return None
clean = urllib.parse.unquote(url.split("?", 1)[0]).replace("\\", "/")
- if clean.startswith("/assets/"):
- root = ASSETS_DIR
- rel = clean[len("/assets/"):]
- else:
- root = OUTPUT_DIR
- rel = clean[len("/output/"):]
+ mappings = (
+ ("/assets/input/", OUTPUT_INPUT_DIR),
+ ("/assets/output/", OUTPUT_OUTPUT_DIR),
+ ("/assets/library/", ASSET_LIBRARY_DIR),
+ ("/assets/uploads/", LOCAL_UPLOAD_DIR),
+ ("/assets/", ASSETS_DIR),
+ ("/output/", OUTPUT_DIR),
+ )
+ prefix, root = next(((prefix, root) for prefix, root in mappings if clean.startswith(prefix)), ("", ""))
+ if not prefix:
+ return None
+ rel = clean[len(prefix):]
rel = rel.lstrip("/")
if not rel:
return None
@@ -4710,6 +5197,27 @@ def output_file_from_url(url):
return None
return path
+def media_url_from_path(path: str):
+ absolute = os.path.abspath(path)
+ mappings = (
+ (OUTPUT_INPUT_DIR, "/assets/input"),
+ (OUTPUT_OUTPUT_DIR, "/assets/output"),
+ (ASSET_LIBRARY_DIR, "/assets/library"),
+ (LOCAL_UPLOAD_DIR, "/assets/uploads"),
+ (OUTPUT_DIR, "/output"),
+ (ASSETS_DIR, "/assets"),
+ )
+ for root, prefix in mappings:
+ root_abs = os.path.abspath(root)
+ try:
+ if os.path.commonpath([root_abs, absolute]) != root_abs:
+ continue
+ except ValueError:
+ continue
+ rel = os.path.relpath(absolute, root_abs).replace("\\", "/")
+ return f"{prefix}/{urllib.parse.quote(rel, safe='/')}"
+ return None
+
def image_has_alpha(img: Image.Image) -> bool:
if img.mode in ("RGBA", "LA"):
return True
@@ -4838,9 +5346,9 @@ def local_media_file_by_basename(name: str):
roots = [
OUTPUT_OUTPUT_DIR,
OUTPUT_INPUT_DIR,
- os.path.join(ASSETS_DIR, "output"),
- os.path.join(ASSETS_DIR, "input"),
- os.path.join(ASSETS_DIR, "library"),
+ ASSET_LIBRARY_DIR,
+ LOCAL_UPLOAD_DIR,
+ OUTPUT_DIR,
]
for root in roots:
path = os.path.abspath(os.path.join(root, safe))
@@ -4859,7 +5367,7 @@ def fetch_remote_media_bytes(url: str, timeout: float = 30.0, max_bytes: int = 2
parsed = urllib.parse.urlparse(text)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
- with requests.get(text, stream=True, timeout=timeout, headers={"User-Agent": "ComfyUI-API-Modelscope/1.0"}) as response:
+ with requests.get(text, stream=True, timeout=timeout, headers={"User-Agent": "Canvas/1.0"}) as response:
response.raise_for_status()
content_type = response.headers.get("content-type") or "application/octet-stream"
chunks = []
@@ -5013,13 +5521,13 @@ def migrate_asset_item_registrations(item):
item.pop(key, None)
def load_asset_library():
- if not os.path.exists(ASSET_LIBRARY_PATH):
+ stored = DATABASE.get_library("asset_library", None)
+ if stored is None:
lib = default_asset_library()
save_asset_library(lib)
return lib
try:
- with open(ASSET_LIBRARY_PATH, "r", encoding="utf-8") as f:
- lib = json.load(f)
+ lib = stored
except Exception:
lib = default_asset_library()
return normalize_asset_library(lib)
@@ -5375,11 +5883,8 @@ def save_asset_library(lib):
lib = normalize_asset_library(lib)
sort_asset_library_items(lib)
lib["updated_at"] = now_ms()
- os.makedirs(DATA_DIR, exist_ok=True)
- with open(ASSET_LIBRARY_PATH, "w", encoding="utf-8") as f:
- json.dump(lib, f, ensure_ascii=False, indent=2)
- if GLOBAL_LOOP:
- asyncio.run_coroutine_threadsafe(manager.broadcast_asset_library_updated(int(lib["updated_at"])), GLOBAL_LOOP)
+ revision = DATABASE.save_library("asset_library", lib)
+ publish_entity_changed("asset", "global", revision, updated_at=int(lib["updated_at"]))
def find_asset_category(lib, category_id):
for cat in lib.get("categories", []):
@@ -5423,11 +5928,7 @@ def find_asset_category_with_library(lib, category_id, library_id=""):
SHARED_FOLDERS_LOCK = Lock()
def shared_folders_load():
- try:
- with open(SHARED_FOLDERS_FILE, "r", encoding="utf-8") as f:
- data = json.load(f)
- except (FileNotFoundError, json.JSONDecodeError):
- data = {}
+ data = DATABASE.get_library("shared_folders", {})
if not isinstance(data, dict):
data = {}
folders = data.get("folders")
@@ -5436,9 +5937,7 @@ def shared_folders_load():
return {"folders": [f for f in folders if isinstance(f, dict)]}
def shared_folders_save(data):
- os.makedirs(DATA_DIR, exist_ok=True)
- with open(SHARED_FOLDERS_FILE, "w", encoding="utf-8") as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
+ DATABASE.save_library("shared_folders", data)
def shared_folder_by_id(folder_id):
for entry in shared_folders_load().get("folders", []):
@@ -5448,16 +5947,16 @@ def shared_folder_by_id(folder_id):
def shared_folder_abs(entry):
rel = (entry or {}).get("rel") or ""
- return os.path.normpath(os.path.join(BASE_DIR, rel))
+ return os.path.normpath(os.path.join(DATA_DIR, rel))
def shared_resolve_register(path):
"""校验 path 必须位于项目目录内、是一个存在的子目录(非项目根)。返回 (abs, rel)。"""
raw = (path or "").strip().strip('"').strip("'")
if not raw:
raise HTTPException(status_code=400, detail="请提供文件夹路径")
- candidate = raw if os.path.isabs(raw) else os.path.join(BASE_DIR, raw)
+ candidate = raw if os.path.isabs(raw) else os.path.join(DATA_DIR, raw)
abs_path = os.path.normpath(os.path.abspath(candidate))
- base = os.path.normpath(os.path.abspath(BASE_DIR))
+ base = os.path.normpath(os.path.abspath(DATA_DIR))
try:
common = os.path.commonpath([abs_path, base])
except ValueError:
@@ -5688,12 +6187,12 @@ def normalize_prompt_libraries(data):
return {"active_library_id": active, "libraries": libraries, "updated_at": int(data.get("updated_at") or now_ms())}
def load_prompt_libraries():
- if not os.path.exists(PROMPT_LIBRARY_PATH):
+ stored = DATABASE.get_library("prompt_libraries", None)
+ if stored is None:
data = default_prompt_libraries()
return save_prompt_libraries(data)
try:
- with open(PROMPT_LIBRARY_PATH, "r", encoding="utf-8") as f:
- data = json.load(f)
+ data = stored
except Exception:
data = default_prompt_libraries()
if not isinstance(data, dict):
@@ -5706,9 +6205,8 @@ def load_prompt_libraries():
def save_prompt_libraries(data):
data = normalize_prompt_libraries(data)
data["updated_at"] = now_ms()
- os.makedirs(DATA_DIR, exist_ok=True)
- with open(PROMPT_LIBRARY_PATH, "w", encoding="utf-8") as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
+ revision = DATABASE.save_library("prompt_libraries", data)
+ publish_entity_changed("prompt", "global", revision, updated_at=int(data["updated_at"]))
return data
def public_prompt_libraries(data=None):
@@ -5823,13 +6321,7 @@ def convert_output_to_jpg(url, quality=88):
else:
img = img.convert("RGB")
img.save(jpg_path, "JPEG", quality=quality, optimize=True)
- try:
- root = ASSETS_DIR if os.path.commonpath([os.path.abspath(ASSETS_DIR), os.path.abspath(jpg_path)]) == os.path.abspath(ASSETS_DIR) else OUTPUT_DIR
- except ValueError:
- root = OUTPUT_DIR
- rel = os.path.relpath(jpg_path, root).replace("\\", "/")
- prefix = "/assets" if root == ASSETS_DIR else "/output"
- return f"{prefix}/{rel}"
+ return media_url_from_path(jpg_path) or url
except Exception as e:
print(f"转换 JPG 失败: {e}")
return url
@@ -7102,7 +7594,7 @@ async def save_remote_video_to_output(url, prefix="video_", category="output"):
try:
timeout = httpx.Timeout(connect=20.0, read=VIDEO_POLL_TIMEOUT, write=60.0, pool=20.0)
headers = {
- "User-Agent": "ComfyUI-API-Modelscope/1.0",
+ "User-Agent": "Canvas/1.0",
"Accept": "video/*,application/octet-stream,*/*;q=0.8",
}
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, headers=headers) as client:
@@ -7276,23 +7768,109 @@ def apimart_size_resolution(size):
best = min(common, key=lambda item: abs(ratio - item[0] / item[1]))
return best[2], resolution
-VOLCENGINE_MIN_PIXELS = 3_686_400
-VOLCENGINE_MIN_EDGE = 1536
-VOLCENGINE_MAX_EDGE = 4096
-VOLCENGINE_RATIO_CHOICES = [
- (1, 1, "1:1"),
- (4, 3, "4:3"),
- (3, 4, "3:4"),
- (16, 9, "16:9"),
- (9, 16, "9:16"),
- (21, 9, "21:9"),
- (9, 21, "9:21"),
- (3, 2, "3:2"),
- (2, 3, "2:3"),
- (5, 4, "5:4"),
- (4, 5, "4:5"),
-]
-
+def grsai_aspect_ratio(size, fallback="1:1"):
+ width, height = parse_size_pair(size)
+ if width and height:
+ divisor = math.gcd(width, height) or 1
+ return f"{width // divisor}:{height // divisor}"
+ raw = str(size or "").strip().lower()
+ if raw == "auto" or re.fullmatch(r"\d+\s*:\s*\d+", raw):
+ return raw.replace(" ", "")
+ return fallback
+
+def grsai_image_size(size, fallback="1K"):
+ width, height = parse_size_pair(size)
+ if width and height:
+ long_edge = max(width, height)
+ if long_edge >= 3200:
+ return "4K"
+ if long_edge >= 1400:
+ return "2K"
+ return "1K"
+ raw = str(size or "").strip().upper()
+ return raw if raw in {"1K", "2K", "4K"} else fallback
+
+def grsai_endpoint_url(provider, path):
+ return provider_endpoint_url(provider, "", path)
+
+def grsai_status(raw):
+ if not isinstance(raw, dict):
+ return ""
+ return str(raw.get("status") or "").strip().lower()
+
+async def wait_for_grsai_image_task(client, provider, task_id):
+ query_url = grsai_endpoint_url(provider, "/v1/api/result")
+ deadline = time.monotonic() + IMAGE_TASK_TIMEOUT
+ last_payload = None
+ while time.monotonic() < deadline:
+ await asyncio.sleep(IMAGE_POLL_INTERVAL)
+ response = await client.get(
+ query_url,
+ headers=api_headers(provider=provider),
+ params={"id": task_id},
+ )
+ response.raise_for_status()
+ raw = response.json()
+ last_payload = raw
+ status = grsai_status(raw)
+ if status == "succeeded":
+ return raw
+ if status in {"failed", "violation"}:
+ raise HTTPException(status_code=502, detail=f"Grsai 任务失败:{raw.get('error') or raw}")
+ try:
+ extract_image(raw)
+ return raw
+ except HTTPException:
+ pass
+ raise HTTPException(status_code=504, detail=f"Grsai 生图任务超时:{last_payload or task_id}")
+
+async def generate_grsai_nano_provider_image(prompt, size, model, reference_images=None, provider=None):
+ endpoint = grsai_endpoint_url(provider, "/v1/api/generate")
+ body = {
+ "model": model,
+ "prompt": prompt,
+ "images": [
+ reference_to_data_url(ref, max_size=1536)
+ for ref in (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX]
+ if ref.get("url")
+ ],
+ "aspectRatio": grsai_aspect_ratio(size),
+ "imageSize": grsai_image_size(size),
+ "replyType": "json",
+ }
+ async with httpx.AsyncClient(timeout=httpx.Timeout(connect=20.0, read=1800.0, write=120.0, pool=20.0)) as client:
+ response = await client.post(endpoint, headers=api_headers(provider=provider), json=body)
+ response.raise_for_status()
+ raw = response.json()
+ status = grsai_status(raw)
+ if status in {"failed", "violation"}:
+ raise HTTPException(status_code=502, detail=f"Grsai 生成失败:{raw.get('error') or raw}")
+ try:
+ return extract_image(raw), raw
+ except HTTPException:
+ task_id = extract_task_id(raw)
+ if not task_id:
+ raise
+ task_result = await wait_for_grsai_image_task(client, provider, task_id)
+ return extract_image(task_result), task_result
+
+VOLCENGINE_MIN_PIXELS = 3_686_400
+VOLCENGINE_MIN_EDGE = 1536
+VOLCENGINE_MAX_EDGE = 4096
+VOLCENGINE_RATIO_CHOICES = [
+ (1, 1, "1:1"),
+ (4, 3, "4:3"),
+ (3, 4, "3:4"),
+ (16, 9, "16:9"),
+ (9, 16, "9:16"),
+ (21, 9, "21:9"),
+ (9, 21, "9:21"),
+ (3, 2, "3:2"),
+ (2, 3, "2:3"),
+ (5, 4, "5:4"),
+ (4, 5, "4:5"),
+]
+
def is_volcengine_seedream_model(model):
value = str(model or "").strip().lower()
return "seedream" in value or "doubao-seedream" in value
@@ -8625,14 +9203,27 @@ async def generate_runninghub_video(payload, provider):
local_urls = [await save_remote_video_to_output(url, prefix="rh_video_") for url in urls]
return {"videos": local_urls, "task_id": task_id, "raw": result}
-async def generate_ai_image(prompt, size, quality, model, reference_images=None, provider_id="comfly"):
+async def generate_ai_image(
+ prompt,
+ size,
+ quality,
+ model,
+ reference_images=None,
+ provider_id="comfly",
+ allow_edit_endpoint_fallback=True,
+ semantic_mask=False,
+):
provider = get_api_provider(provider_id)
if provider["id"] == "modelscope":
return await generate_modelscope_provider_image(prompt, size, model, reference_images, provider)
+ if is_codex_provider(provider):
+ return await generate_codex_provider_image(prompt, size, model, reference_images, provider)
if is_jimeng_provider(provider):
return await generate_jimeng_provider_image(prompt, size, model, reference_images, provider)
if is_runninghub_provider(provider):
return await generate_runninghub_provider_image(prompt, size, model, reference_images, provider)
+ if is_grsai_provider(provider) and is_grsai_nano_model(model):
+ return await generate_grsai_nano_provider_image(prompt, size, model, reference_images, provider)
if effective_protocol(provider, model) == "gemini":
return await generate_gemini_provider_image(prompt, size, model, reference_images, provider)
if is_volcengine_provider(provider):
@@ -8650,8 +9241,8 @@ async def generate_ai_image(prompt, size, quality, model, reference_images=None,
gen_url = provider_endpoint_url(provider, "image_generation_endpoint", "/v1/images/generations")
edit_url = provider_endpoint_url(provider, "image_edit_endpoint", "/v1/images/edits")
refs = [ref for ref in (reference_images or []) if ref.get("url")]
- mask_refs = [ref for ref in refs if str(ref.get("role") or "").strip().lower() == "mask" or str(ref.get("name") or "").lower().endswith("_mask.png")]
- image_refs = [ref for ref in refs if ref not in mask_refs]
+ mask_refs = [] if semantic_mask else [ref for ref in refs if str(ref.get("role") or "").strip().lower() == "mask" or str(ref.get("name") or "").lower().endswith("_mask.png")]
+ image_refs = refs if semantic_mask else [ref for ref in refs if ref not in mask_refs]
image_request_mode = effective_image_request_mode(provider, model)
request_timeout = httpx.Timeout(connect=20.0, read=1800.0, write=120.0, pool=20.0) if (is_gpt2 or is_apimart or image_request_mode == "openai-json") else AI_REQUEST_TIMEOUT
async with httpx.AsyncClient(timeout=request_timeout) as client:
@@ -8734,10 +9325,12 @@ async def post_openai_edits(edit_files=None):
fh.close()
# 2) edits 失败 → 非 GPT-Image-2 可回退到 /images/generations + JSON image:[urls/base64](grsai 风格)
if response is None:
- if is_gpt2:
+ if is_gpt2 or not allow_edit_endpoint_fallback:
+ failure_status = edit_failed_status if isinstance(edit_failed_status, int) and edit_failed_status >= 400 else 502
+ failure_prefix = "GPT-Image-2 编辑接口" if is_gpt2 else "图片编辑接口"
raise HTTPException(
- status_code=502,
- detail=f"GPT-Image-2 编辑接口 /images/edits 调用失败:{edit_failed_text[:300] or edit_failed_status}。已停止自动重试,避免上游可能已扣费后再次请求。"
+ status_code=failure_status,
+ detail=f"{failure_prefix} /images/edits 调用失败:{edit_failed_text[:300] or edit_failed_status}。已停止自动补发,避免上游状态未知时重复扣费。"
)
print(f"/images/edits failed ({edit_failed_status}): {edit_failed_text[:200]} → 回退到 /images/generations + image:[] JSON")
image_payload = [reference_to_data_url(ref, max_size=1536) for ref in image_refs[:ONLINE_IMAGE_REFERENCE_MAX]]
@@ -8969,8 +9562,21 @@ async def decide_chat_agent_action(payload, conversation, refs):
return fallback
async def build_chat_text_reply(payload, conversation):
- chat_base, chat_hdrs, model = resolve_chat_provider(payload.provider, payload.model, payload.ms_model)
provider_cfg = get_api_provider(payload.provider) if payload.provider not in ("modelscope",) else {}
+ if is_codex_provider(provider_cfg):
+ model = selected_model(payload.model, (provider_cfg.get("chat_models") or CODEX_DEFAULT_CHAT_MODELS)[0])
+ payload.model = model
+ text, raw = await codex_chat_text(payload, conversation["messages"][-MAX_HISTORY_MESSAGES:])
+ return {
+ "id": uuid.uuid4().hex,
+ "role": "assistant",
+ "content": text,
+ "created_at": now_ms(),
+ "model": model,
+ "raw_usage": None,
+ "raw": raw,
+ }
+ chat_base, chat_hdrs, model = resolve_chat_provider(payload.provider, payload.model, payload.ms_model)
is_apimart = is_apimart_provider(provider_cfg)
upstream_messages = [{"role": "system", "content": chat_system_prompt(payload)}]
for item in conversation["messages"][-MAX_HISTORY_MESSAGES:]:
@@ -9009,26 +9615,14 @@ async def index():
@app.get("/api/view")
def view_image(filename: str, type: str = "input", subfolder: str = ""):
- # 先按原逻辑去各 ComfyUI 后端找
- for addr in COMFYUI_INSTANCES:
- try:
- url = f"http://{addr}/view"
- params = {"filename": filename, "type": type, "subfolder": subfolder}
- r = requests.get(url, params=params, timeout=1)
- if r.status_code == 200:
- return Response(content=r.content, media_type=r.headers.get('Content-Type'))
- except Exception:
- continue
- # 后端都拿不到时回退本地 assets//
- # 适用场景:画布通过 /api/ai/upload 把参考图直接落到本地 assets/input/,
- # 但 ComfyUI 的 input 可能因为重启/清理而丢失,导致 enhance/klein 等页面预览对比图 404
+ # 兼容旧素材链接,仅从 Canvas 自身的输入/输出目录读取。
if not subfolder and type in ("input", "output"):
safe_name = os.path.basename(filename or "")
if safe_name:
local_path = output_path_for(safe_name, "input" if type == "input" else "output")
if os.path.isfile(local_path):
return FileResponse(local_path, media_type=content_type_for_path(local_path))
- raise HTTPException(status_code=404, detail="Image not found on any available backend")
+ raise HTTPException(status_code=404, detail="Image not found")
@app.get("/api/download-output")
def download_output(request: Request, url: str, name: str = "", inline: bool = False):
@@ -9043,7 +9637,7 @@ def download_output(request: Request, url: str, name: str = "", inline: bool = F
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise HTTPException(status_code=400, detail="无效的下载地址")
try:
- upstream_headers = {"User-Agent": "ComfyUI-API-Modelscope/1.0"}
+ upstream_headers = {"User-Agent": "Canvas/1.0"}
range_header = request.headers.get("range")
if range_header:
upstream_headers["Range"] = range_header
@@ -9077,33 +9671,6 @@ def stream_remote():
return StreamingResponse(stream_remote(), media_type=content_type, headers=headers, status_code=upstream.status_code)
-@app.post("/api/upload")
-async def upload_image(files: List[UploadFile] = File(...)):
- uploaded_files = []
- files_content = []
- for file in files:
- content = await file.read()
- files_content.append((file, content))
-
- for file, content in files_content:
- success_count = 0
- last_result = None
- for addr in COMFYUI_INSTANCES:
- try:
- files_data = {'image': (file.filename, content, file.content_type)}
- response = requests.post(f"http://{addr}/upload/image", files=files_data, timeout=5)
- if response.status_code == 200:
- last_result = response.json()
- success_count += 1
- except Exception as e:
- print(f"Upload error for {addr}: {e}")
-
- if success_count > 0 and last_result:
- uploaded_files.append({"comfy_name": last_result.get("name", file.filename)})
- else:
- raise HTTPException(status_code=500, detail="Failed to upload to any backend")
-
- return {"files": uploaded_files}
@app.post("/api/ai/upload")
async def upload_ai_reference(files: List[UploadFile] = File(...)):
@@ -9181,35 +9748,6 @@ async def upload_ai_base64(payload: Base64UploadRequest):
f.write(content)
return {"files": [{"url": output_url_for(filename, "input"), "name": payload.name or filename, "kind": kind}]}
-@app.post("/api/comfyui/upload-base64")
-async def upload_comfyui_base64(payload: Base64UploadRequest):
- """base64 方式把图片传到 ComfyUI 各后端的 input 目录,返回 comfy 用文件名(供 UXP 做 ComfyUI 图生图)。"""
- raw = (payload.data or "").strip()
- ct = (payload.content_type or "").split(";", 1)[0].strip().lower()
- if raw.startswith("data:"):
- header, _, raw = raw.partition(",")
- if not ct:
- ct = header[5:].split(";", 1)[0].strip().lower()
- try:
- content = base64.b64decode(raw, validate=False)
- except Exception:
- raise HTTPException(status_code=400, detail="数据无法解码")
- if not content:
- raise HTTPException(status_code=400, detail="内容为空")
- _, ext = _local_upload_kind_ext(payload.name or "", ct or "image/png")
- filename = f"dx_{uuid.uuid4().hex[:12]}{ext or '.png'}"
- comfy_name = None
- for addr in COMFYUI_INSTANCES:
- try:
- resp = requests.post(f"http://{addr}/upload/image",
- files={'image': (filename, content, ct or 'image/png')}, timeout=10)
- if resp.status_code == 200:
- comfy_name = resp.json().get("name", filename)
- except Exception as exc:
- print(f"ComfyUI base64 upload error for {addr}: {exc}")
- if not comfy_name:
- raise HTTPException(status_code=502, detail="上传到 ComfyUI 失败")
- return {"name": comfy_name}
def _local_upload_kind_ext(filename, content_type):
image_exts = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
@@ -10155,6 +10693,67 @@ async def runninghub_upload_asset(payload: RunningHubUploadAssetRequest):
return {"success": True, "data": {"fileName": raw["data"]["fileName"], "fileType": raw["data"].get("fileType") or content_type}}
raise HTTPException(status_code=400, detail=(raw.get("msg") if isinstance(raw, dict) else "") or f"RunningHub 上传失败:{raw}")
+@app.get("/api/codex/status")
+async def codex_status():
+ exe = codex_cli_executable()
+ if not exe:
+ return {
+ "installed": False,
+ "logged_in": False,
+ "message": "未找到 OpenAI Codex CLI,请先安装。",
+ }
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ exe,
+ "--version",
+ cwd=BASE_DIR,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
+ out_text, err_text = codex_decode_output(stdout, stderr)
+ ok = proc.returncode == 0
+ return {
+ "installed": ok,
+ "logged_in": None,
+ "version": out_text or err_text,
+ "path": exe,
+ "message": "OpenAI Codex CLI 已安装。登录状态会在首次执行 codex exec 时由 CLI 校验。" if ok else (err_text or out_text or "Codex CLI 检测失败"),
+ "raw": {"stdout": out_text, "stderr": err_text, "returncode": proc.returncode},
+ }
+ except Exception as exc:
+ return {
+ "installed": False,
+ "logged_in": False,
+ "path": exe,
+ "message": f"Codex CLI 检测失败:{exc}",
+ }
+
+@app.post("/api/codex/help")
+async def codex_help(payload: CodexHelpRequest):
+ exe = codex_cli_executable()
+ if not exe:
+ raise HTTPException(status_code=400, detail="未找到 OpenAI Codex CLI。")
+ allowed = {"", "exec", "login", "logout", "doctor", "mcp", "app", "update"}
+ command = str(payload.command or "").strip()
+ if command not in allowed:
+ raise HTTPException(status_code=400, detail="不允许的 Codex CLI 命令")
+ args = [exe]
+ if command:
+ args.append(command)
+ args.append("--help")
+ proc = await asyncio.create_subprocess_exec(
+ *args,
+ cwd=BASE_DIR,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=20)
+ out_text, err_text = codex_decode_output(stdout, stderr)
+ if proc.returncode != 0:
+ raise HTTPException(status_code=502, detail=(err_text or out_text or f"exit={proc.returncode}")[:1000])
+ return {"text": out_text or err_text, "raw": {"stdout": out_text, "stderr": err_text}}
+
@app.get("/api/jimeng/status")
async def jimeng_status():
exe = jimeng_cli_executable()
@@ -10307,7 +10906,6 @@ async def ai_config():
"chat_models": CHAT_MODELS,
"image_models": IMAGE_MODELS,
"video_models": VIDEO_MODELS,
- "comfy_instances": COMFYUI_INSTANCES,
"api_providers": providers,
"has_api_key": bool(AI_API_KEY),
"ms_chat_models": MODELSCOPE_CHAT_MODELS,
@@ -10386,18 +10984,7 @@ async def save_providers(payload: List[ApiProviderPayload]):
@app.get("/api/config/token")
async def get_global_token():
- # 优先读 env,回退到 global_config.json(兼容旧数据)
- saved_token = modelscope_api_key()
- if saved_token:
- return {"token": saved_token}
- if os.path.exists(GLOBAL_CONFIG_FILE):
- try:
- with open(GLOBAL_CONFIG_FILE, 'r', encoding='utf-8') as f:
- config = json.load(f)
- return {"token": config.get("modelscope_token", "")}
- except:
- pass
- return {"token": ""}
+ return {"configured": bool(modelscope_api_key())}
# --- 在线生图 (COMFLY) ---
@@ -10416,6 +11003,8 @@ def protocol_from_payload(payload):
return "runninghub"
if provider_id == "jimeng":
return "jimeng"
+ if provider_id == "codex":
+ return "codex"
base_url = str(getattr(payload, "base_url", "") or "").strip().lower()
if "runninghub.cn" in base_url or "runninghub.ai" in base_url:
return "runninghub"
@@ -10631,6 +11220,15 @@ def apply_agnes_model_defaults(base_url, grouped, ids):
async def test_provider_connection(payload: TestConnectionPayload):
"""测试请求地址是否可用:调上游 /v1/models。验证通过时同时把模型清单按类别返回,避免再调一次拉取接口。"""
protocol = protocol_from_payload(payload)
+ if protocol == "codex":
+ status = await codex_status()
+ payload_models = codex_models_payload(raw={"status": status})
+ payload_models.update({
+ "ok": bool(status.get("installed")),
+ "status": 200 if status.get("installed") else 0,
+ "message": status.get("message") or ("OpenAI Codex CLI 可用" if status.get("installed") else "未找到 OpenAI Codex CLI"),
+ })
+ return payload_models
if protocol == "jimeng":
status = await jimeng_status()
return {
@@ -10726,9 +11324,18 @@ async def probe_async_endpoint(payload: TestConnectionPayload):
"""验证异步协议:用假 task_id 请求 GET /v1/tasks/{fake_id}。
收到 400 Invalid task ID = 端点存在且 Key 有效;401/403 = Key 无效;404/连接失败 = 不支持异步端点。"""
base_url = (payload.base_url or "").strip().rstrip("/")
+ protocol = protocol_from_payload(payload)
+ if protocol == "codex":
+ status = await codex_status()
+ return {
+ "ok": bool(status.get("installed")),
+ "protocol": "codex",
+ "status_code": 200 if status.get("installed") else 0,
+ "message": status.get("message") or "OpenAI Codex CLI 本机检测完成",
+ "raw": status,
+ }
if not base_url:
raise HTTPException(status_code=400, detail="请先填写请求地址")
- protocol = protocol_from_payload(payload)
api_key = api_key_from_payload(payload, protocol)
if not api_key:
raise HTTPException(status_code=400, detail="请先填写或保存 API Key")
@@ -10852,6 +11459,11 @@ async def probe_async_endpoint(payload: TestConnectionPayload):
async def fetch_models_from_upstream(base_url: str, api_key: str, protocol: str = "openai", image_request_mode: str = "openai"):
"""从上游模型列表端点拉取模型,并按名称做轻量分类。"""
protocol = protocol if protocol in SUPPORTED_PROVIDER_PROTOCOLS else "openai"
+ if protocol == "codex":
+ status = await codex_status()
+ payload = codex_models_payload(raw={"status": status})
+ payload["message"] = status.get("message") or payload["message"]
+ return payload
if protocol == "jimeng":
return {
"total": len(JIMENG_DEFAULT_IMAGE_MODELS) + len(JIMENG_DEFAULT_VIDEO_MODELS),
@@ -10979,6 +11591,8 @@ async def fetch_upstream_models_from_payload(payload: TestConnectionPayload):
async def fetch_upstream_models(provider_id: str):
"""从已保存的上游 OpenAI 兼容接口拉取 /v1/models 列表,按名称智能分类为 image/chat/video。"""
provider = get_api_provider_exact(provider_id)
+ if is_codex_provider(provider):
+ return await fetch_models_from_upstream("", "", "codex", provider.get("image_request_mode") or "openai")
api_key = os.getenv(runninghub_wallet_key_env(), "") if provider["id"] == "runninghub" else ""
if not api_key:
api_key = os.getenv(provider_key_env(provider["id"]), "")
@@ -10986,15 +11600,37 @@ async def fetch_upstream_models(provider_id: str):
raise HTTPException(status_code=400, detail=f"{provider.get('name') or provider_id} 未配置 API Key")
return await fetch_models_from_upstream(provider.get("base_url") or "", api_key, provider_protocol(provider), provider.get("image_request_mode") or "openai")
-async def build_online_image_result(payload: OnlineImageRequest):
- provider = get_api_provider(payload.provider_id)
+async def execute_ai_image_batch(
+ prompt: str,
+ provider_id: str,
+ model: str,
+ size: str,
+ quality: str,
+ references: List[Dict[str, Any]],
+ count: int,
+ prefix: str,
+ allow_edit_endpoint_fallback: bool = True,
+ semantic_mask: bool = False,
+) -> Dict[str, Any]:
+ provider = get_api_provider(provider_id)
default_model = (provider.get("image_models") or [IMAGE_MODEL])[0]
- model = selected_model(payload.model, default_model)
- refs = [ref.dict() for ref in payload.reference_images if ref.url]
+ resolved_model = selected_model(model, default_model)
+ refs = [dict(ref) for ref in references or [] if isinstance(ref, dict) and ref.get("url")]
image_refs = image_references(refs)
- count = max(1, min(8, int(payload.n or 1)))
+ safe_count = max(1, min(8, int(count or 1)))
+ generation_started_at = time.time()
+
async def generate_one():
- image_data, raw_item = await generate_ai_image(payload.prompt, payload.size, payload.quality, model, image_refs, provider["id"])
+ image_data, raw_item = await generate_ai_image(
+ prompt,
+ size,
+ quality,
+ resolved_model,
+ image_refs,
+ provider["id"],
+ allow_edit_endpoint_fallback=allow_edit_endpoint_fallback,
+ semantic_mask=semantic_mask,
+ )
try:
image_items = extract_images(raw_item) if isinstance(raw_item, dict) else [image_data]
except HTTPException:
@@ -11002,21 +11638,22 @@ async def generate_one():
local_urls = []
local_items = []
for item in image_items:
- local_url = await save_ai_image_to_output(item, prefix="online_")
+ local_url = await save_ai_image_to_output(item, prefix=prefix)
if local_url:
local_urls.append(local_url)
local_items.append(image_output_meta(local_url, item))
return local_urls, local_items, raw_item
+
try:
- generated = await asyncio.gather(*(generate_one() for _ in range(count)))
+ generated = await asyncio.gather(*(generate_one() for _ in range(safe_count)))
except httpx.HTTPStatusError as exc:
- log_net_error(f"生图 HTTP状态错误 provider={provider.get('id')} model={model} size={payload.size}", exc)
+ log_net_error(f"生图 HTTP状态错误 provider={provider.get('id')} model={resolved_model} size={size}", exc)
text = exc.response.text or ''
- friendly = friendly_image_error_detail(text, payload.size, model)
+ friendly = friendly_image_error_detail(text, size, resolved_model)
detail = friendly or f"上游生图接口错误:{text[:300]}"
raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
except httpx.HTTPError as exc:
- log_net_error(f"生图 网络/TLS错误 provider={provider.get('id')} model={model}", exc)
+ log_net_error(f"生图 网络/TLS错误 provider={provider.get('id')} model={resolved_model}", exc)
raise HTTPException(status_code=502, detail=f"请求上游生图接口失败:{exc}") from exc
local_urls = [url for urls, _items, _raw in generated for url in (urls or []) if url]
@@ -11026,18 +11663,55 @@ async def generate_one():
provider_name = provider.get("name") or provider["id"]
raw_text = json.dumps(raw, ensure_ascii=False)[:800] if isinstance(raw, (dict, list)) else str(raw)[:800]
raise HTTPException(status_code=502, detail=f"{provider_name} 没有返回图片:{raw_text}")
- result = {
- "prompt": payload.prompt,
+ generation_completed_at = time.time()
+ generation_elapsed_seconds = round(max(0, generation_completed_at - generation_started_at), 3)
+ return {
+ "provider": provider,
+ "model": resolved_model,
+ "count": safe_count,
+ "references": refs,
"images": local_urls,
"image_items": local_items,
- "timestamp": time.time(),
+ "raw": raw,
+ "generation_started_at": generation_started_at,
+ "generation_completed_at": generation_completed_at,
+ "generation_elapsed_seconds": generation_elapsed_seconds,
+ }
+
+async def build_online_image_result(payload: OnlineImageRequest):
+ batch = await execute_ai_image_batch(
+ prompt=payload.prompt,
+ provider_id=payload.provider_id,
+ model=payload.model,
+ size=payload.size,
+ quality=payload.quality,
+ references=[ref.dict() for ref in payload.reference_images if ref.url],
+ count=payload.n,
+ prefix="online_",
+ )
+ provider = batch["provider"]
+ model = batch["model"]
+ refs = batch["references"]
+ count = batch["count"]
+ raw = batch["raw"]
+ generation_started_at = batch["generation_started_at"]
+ generation_completed_at = batch["generation_completed_at"]
+ generation_elapsed_seconds = batch["generation_elapsed_seconds"]
+ result = {
+ "prompt": payload.prompt,
+ "images": batch["images"],
+ "image_items": batch["image_items"],
+ "timestamp": generation_completed_at,
"type": "online",
"model": model,
"provider_id": provider["id"],
"provider_name": provider.get("name") or provider["id"],
"task_id": extract_task_id(raw) if isinstance(raw, dict) else None,
"request_id": raw.get("id") if isinstance(raw, dict) else None,
- "params": {"provider_id": provider["id"], "model": model, "size": payload.size, "quality": payload.quality, "n": count, "reference_images": refs},
+ "generation_started_at": generation_started_at,
+ "generation_completed_at": generation_completed_at,
+ "generation_elapsed_seconds": generation_elapsed_seconds,
+ "params": {"provider_id": provider["id"], "model": model, "size": payload.size, "quality": payload.quality, "n": count, "reference_images": refs, "generation_elapsed_seconds": generation_elapsed_seconds},
"raw_usage": raw.get("usage") if isinstance(raw, dict) else None,
}
save_to_history(result)
@@ -11049,6 +11723,796 @@ async def generate_one():
async def online_image(payload: OnlineImageRequest):
return await build_online_image_result(payload)
+ONLINE_IMAGE_ACTIVE_STATUSES = {"queued", "running"}
+ONLINE_IMAGE_TASK_RESTART_ERROR = "服务已重启,本地未完成任务已中断"
+ONLINE_IMAGE_TASK_RECOVERY_MESSAGE = "服务已重启,已保留云端任务 ID,可继续查询"
+
+def online_image_request_snapshot(payload: OnlineImageRequest) -> Dict[str, Any]:
+ refs = [ref.dict() for ref in payload.reference_images if ref.url]
+ return {
+ "prompt": payload.prompt,
+ "provider_id": payload.provider_id,
+ "model": payload.model,
+ "size": payload.size,
+ "quality": payload.quality,
+ "n": max(1, min(8, int(payload.n or 1))),
+ "reference_images": refs,
+ }
+
+def write_online_image_tasks_locked():
+ tasks = sorted(
+ ONLINE_IMAGE_TASKS.values(),
+ key=lambda item: float(item.get("created_at") or 0),
+ reverse=True,
+ )[:500]
+ DATABASE.save_tasks("online_image", tasks)
+ publish_entity_changed("task", "online_image")
+
+def load_online_image_tasks_from_disk():
+ items = DATABASE.load_tasks("online_image")
+ if not isinstance(items, list):
+ return
+ now = time.time()
+ changed = False
+ restored = {}
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ task_id = str(item.get("id") or item.get("task_id") or "").strip()
+ if not task_id:
+ continue
+ task = dict(item)
+ task["id"] = task_id
+ task["task_id"] = task_id
+ if task.get("status") in ONLINE_IMAGE_ACTIVE_STATUSES:
+ recoverable_id = next((str(task.get(key) or "").strip() for key in (
+ "upstream_task_id", "submit_id", "taskId", "video_id", "asset_id"
+ ) if str(task.get(key) or "").strip()), "")
+ if recoverable_id:
+ task["status"] = "recovery_pending"
+ task["recovery_task_id"] = recoverable_id
+ task["message"] = ONLINE_IMAGE_TASK_RECOVERY_MESSAGE
+ task["error"] = ""
+ else:
+ task["status"] = "interrupted"
+ task["error"] = ONLINE_IMAGE_TASK_RESTART_ERROR
+ task["updated_at"] = now
+ changed = True
+ restored[task_id] = task
+ with ONLINE_IMAGE_TASK_LOCK:
+ ONLINE_IMAGE_TASKS.clear()
+ ONLINE_IMAGE_TASKS.update(restored)
+ if changed:
+ write_online_image_tasks_locked()
+
+def update_online_image_task(task_id: str, changes: Dict[str, Any]):
+ with ONLINE_IMAGE_TASK_LOCK:
+ task = ONLINE_IMAGE_TASKS.get(task_id)
+ if not task:
+ return
+ task.update(changes)
+ task["updated_at"] = time.time()
+ write_online_image_tasks_locked()
+
+def public_online_image_task(task: Dict[str, Any]) -> Dict[str, Any]:
+ data = dict(task or {})
+ task_id = str(data.get("id") or data.get("task_id") or "")
+ data["id"] = task_id
+ data["task_id"] = task_id
+ return data
+
+async def run_online_image_task(task_id: str, payload: OnlineImageRequest):
+ update_online_image_task(task_id, {"status": "running", "error": ""})
+ try:
+ result = await build_online_image_result(payload)
+ update_online_image_task(task_id, {
+ "status": "succeeded",
+ "result": result,
+ "error": "",
+ "provider_id": result.get("provider_id") or payload.provider_id,
+ "model": result.get("model") or payload.model,
+ })
+ except JimengPendingError as exc:
+ info = jimeng_pending_payload(exc)
+ update_online_image_task(task_id, {
+ "status": "jimeng_pending",
+ "jimeng_pending": True,
+ "submit_id": exc.submit_id,
+ "kind": exc.kind,
+ "queue_info": exc.queue_info,
+ "message": info["message"],
+ "error": "",
+ })
+ except Exception as exc:
+ detail = getattr(exc, "detail", None) or str(exc)
+ status_code = getattr(exc, "status_code", 500)
+ update_online_image_task(task_id, {
+ "status": "failed",
+ "error": str(detail),
+ "status_code": status_code,
+ "upstream_task_id": getattr(exc, "upstream_task_id", "") or extract_task_id_from_text(str(detail)),
+ })
+
+@app.post("/api/online-image-tasks")
+async def create_online_image_task(payload: OnlineImageRequest):
+ task_id = f"online_img_{uuid.uuid4().hex}"
+ snapshot = online_image_request_snapshot(payload)
+ now = time.time()
+ task = {
+ "id": task_id,
+ "task_id": task_id,
+ "type": "online-image",
+ "status": "queued",
+ "created_at": now,
+ "updated_at": now,
+ "result": None,
+ "error": "",
+ "queue_info": {},
+ "submit_id": "",
+ "message": "",
+ **snapshot,
+ "request": snapshot,
+ }
+ with ONLINE_IMAGE_TASK_LOCK:
+ ONLINE_IMAGE_TASKS[task_id] = task
+ write_online_image_tasks_locked()
+ asyncio.create_task(run_online_image_task(task_id, payload))
+ return public_online_image_task(task)
+
+@app.get("/api/online-image-tasks")
+async def list_online_image_tasks(limit: int = 50):
+ safe_limit = max(1, min(200, int(limit or 50)))
+ with ONLINE_IMAGE_TASK_LOCK:
+ tasks = sorted(
+ (public_online_image_task(task) for task in ONLINE_IMAGE_TASKS.values()),
+ key=lambda item: float(item.get("created_at") or 0),
+ reverse=True,
+ )[:safe_limit]
+ return {"tasks": tasks}
+
+@app.get("/api/online-image-tasks/{task_id}")
+async def get_online_image_task(task_id: str):
+ with ONLINE_IMAGE_TASK_LOCK:
+ task = public_online_image_task(ONLINE_IMAGE_TASKS.get(task_id) or {})
+ if not task.get("id"):
+ raise HTTPException(status_code=404, detail="在线生图任务不存在,可能服务已重启或任务已过期")
+ return task
+
+ECOMMERCE_ACTIVE_STATUSES = {"queued", "running"}
+ECOMMERCE_TASK_RESTART_ERROR = "服务已重启,未完成的电商任务已中断;为避免重复扣费,系统不会自动补发"
+ECOMMERCE_INPUT_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
+ECOMMERCE_INPUT_MAX_BYTES = 50 * 1024 * 1024
+ECOMMERCE_INPUT_FORMAT_MIMES = {
+ "PNG": "image/png",
+ "JPEG": "image/jpeg",
+ "MPO": "image/jpeg",
+ "WEBP": "image/webp",
+}
+ECOMMERCE_GARMENT_ANALYSIS_PROMPT = """请只分析图片中的服装产品,输出严格 JSON,不要 Markdown、不要解释:
+{"category":"upper|lower|dress","garment_type":"具体服装名称","confidence":0.0,"reason":"简短判断依据"}
+category 只能是 upper(上装)、lower(下装)或 dress(连衣裙/连体衣)。如果图片中有模特,仍只判断需要试穿的主要服装。"""
+ECOMMERCE_UNIVERSAL_REFERENCE_ANALYSIS_PROMPT = """请分析这张电商全能模式参考图。参考角色:{role}。用户标签:{label}。用户单图要求:{instruction}。
+输出严格 JSON,不要 Markdown、不要解释:
+{{"item_name":"具体主体/商品/场景名称","category":"服装/鞋/项链/包/手机/动作/场景等","interaction":"wear|put_on|hold|carry|place|use|pose|scene|style|identity","placement":"建议佩戴、手持、背挎、放置或场景位置","visual_details":"需要保留的颜色、材质、结构、Logo、文字和关键特征","confidence":0.0,"reason":"简短依据"}}
+interaction 选择规则:衣服/首饰/帽子/眼镜/腰带通常 wear;鞋子 put_on;手机、杯子、相机等小物 hold;手提包/托特包/背包 carry;家具/摆件 place;动作图 pose;场景图 scene;风格图 style;主体图 identity。"""
+
+def public_ecommerce_route(route: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ key: route.get(key)
+ for key in ("provider_id", "provider_name", "model", "supports_multi_reference", "supports_mask", "max_reference_images")
+ if key in route
+ }
+
+def public_ecommerce_task(task: Dict[str, Any]) -> Dict[str, Any]:
+ data = dict(task or {})
+ task_id = str(data.get("id") or data.get("task_id") or "")
+ data["id"] = task_id
+ data["task_id"] = task_id
+ return data
+
+def write_ecommerce_task_locked(task: Dict[str, Any]):
+ if hasattr(DATABASE, "upsert_task"):
+ DATABASE.upsert_task("ecommerce", task)
+ else:
+ DATABASE.save_tasks("ecommerce", ECOMMERCE_TASKS.values())
+ publish_entity_changed("task", "ecommerce")
+
+def load_ecommerce_tasks_from_disk():
+ items = DATABASE.load_tasks("ecommerce")
+ if not isinstance(items, list):
+ return
+ now = time.time()
+ changed = False
+ restored = {}
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ task_id = str(item.get("id") or item.get("task_id") or "").strip()
+ if not task_id:
+ continue
+ task = dict(item)
+ task["id"] = task_id
+ task["task_id"] = task_id
+ if task.get("status") in ECOMMERCE_ACTIVE_STATUSES:
+ task.update({
+ "status": "interrupted",
+ "error": ECOMMERCE_TASK_RESTART_ERROR,
+ "updated_at": now,
+ })
+ changed = True
+ restored[task_id] = task
+ with ECOMMERCE_TASK_LOCK:
+ ECOMMERCE_TASKS.clear()
+ ECOMMERCE_TASKS.update(restored)
+ if changed:
+ if hasattr(DATABASE, "upsert_task"):
+ for task in restored.values():
+ DATABASE.upsert_task("ecommerce", task)
+ else:
+ DATABASE.save_tasks("ecommerce", restored.values())
+ publish_entity_changed("task", "ecommerce")
+
+def update_ecommerce_task(task_id: str, changes: Dict[str, Any]):
+ with ECOMMERCE_TASK_LOCK:
+ task = ECOMMERCE_TASKS.get(task_id)
+ if not task:
+ return
+ task.update(changes)
+ task["updated_at"] = time.time()
+ write_ecommerce_task_locked(task)
+
+def configured_ecommerce_vision_route(providers: Optional[List[Dict[str, Any]]] = None) -> Optional[Dict[str, str]]:
+ candidates = []
+ preferred_provider_id = str(os.getenv("ECOMMERCE_VISION_PROVIDER_ID", "local-vision") or "").strip().lower()
+ for provider_index, provider in enumerate(providers if providers is not None else load_api_providers()):
+ if not isinstance(provider, dict) or not provider.get("enabled", True):
+ continue
+ provider_id = str(provider.get("id") or "").strip().lower()
+ if not provider_id or not provider_env_key_value(provider_id):
+ continue
+ provider_name = str(provider.get("name") or provider_id)
+ for model_index, model in enumerate(provider.get("chat_models") or []):
+ model_name = str(model or "").strip()
+ if not looks_like_vision_chat_model(model_name):
+ continue
+ name_hint = f"{provider_id} {provider_name}".lower()
+ candidates.append((
+ 0 if provider_id == preferred_provider_id else 1,
+ 0 if any(hint in name_hint for hint in ("vision", "视觉", "vlm")) else 1,
+ provider_index,
+ model_index,
+ {"provider_id": provider_id, "provider_name": provider_name, "model": model_name},
+ ))
+ candidates.sort(key=lambda item: item[:4])
+ return candidates[0][4] if candidates else None
+
+async def analyze_ecommerce_garment(inputs: List[Dict[str, Any]]) -> Dict[str, Any]:
+ garment = next((item for item in inputs if str(item.get("role") or "").lower() == "garment"), None)
+ path = output_file_from_url((garment or {}).get("url") or "")
+ route = configured_ecommerce_vision_route()
+ if not path or not route:
+ return {"status": "skipped", "category": "auto", "garment_type": "", "confidence": 0.0, "reason": ""}
+ try:
+ text, resolved_model = await caption_image_with_provider(
+ path,
+ ECOMMERCE_GARMENT_ANALYSIS_PROMPT,
+ route["provider_id"],
+ route["model"],
+ )
+ analysis = parse_ecommerce_garment_analysis(text)
+ analysis.update({
+ "status": "succeeded" if analysis.get("category") != "auto" else "unrecognized",
+ "provider_id": route["provider_id"],
+ "provider_name": route["provider_name"],
+ "model": resolved_model,
+ })
+ return analysis
+ except Exception:
+ return {"status": "failed", "category": "auto", "garment_type": "", "confidence": 0.0, "reason": ""}
+
+def ecommerce_universal_analysis_prompt(item: Dict[str, Any]) -> str:
+ role = str(item.get("reference_type") or item.get("role") or "").strip()
+ label = str(item.get("label") or item.get("name") or "").strip()
+ instruction = str(item.get("instruction") or "").strip()
+ return ECOMMERCE_UNIVERSAL_REFERENCE_ANALYSIS_PROMPT.format(
+ role=role or "unknown",
+ label=label or "无",
+ instruction=instruction or "无",
+ )
+
+def ecommerce_vision_cache_key(path: str, prompt: str, route: Dict[str, str]) -> str:
+ stat = os.stat(path)
+ identity = "|".join((
+ os.path.abspath(path), str(stat.st_mtime_ns), str(stat.st_size),
+ str(route.get("provider_id") or ""), str(route.get("model") or ""), prompt,
+ ))
+ return hashlib.sha256(identity.encode("utf-8", "ignore")).hexdigest()
+
+def get_cached_ecommerce_vision_analysis(cache_key: str) -> Optional[Dict[str, Any]]:
+ with ECOMMERCE_VISION_CACHE_LOCK:
+ cached = ECOMMERCE_VISION_CACHE.pop(cache_key, None)
+ if cached is None:
+ return None
+ ECOMMERCE_VISION_CACHE[cache_key] = cached
+ return dict(cached)
+
+def cache_ecommerce_vision_analysis(cache_key: str, analysis: Dict[str, Any]):
+ with ECOMMERCE_VISION_CACHE_LOCK:
+ ECOMMERCE_VISION_CACHE.pop(cache_key, None)
+ ECOMMERCE_VISION_CACHE[cache_key] = dict(analysis)
+ while len(ECOMMERCE_VISION_CACHE) > ECOMMERCE_VISION_CACHE_LIMIT:
+ ECOMMERCE_VISION_CACHE.pop(next(iter(ECOMMERCE_VISION_CACHE)))
+
+async def analyze_ecommerce_universal_reference(index: int, item: Dict[str, Any], route: Dict[str, str]) -> Tuple[str, Dict[str, Any]]:
+ reference_id = str(item.get("reference_id") or f"reference_{index + 1}")
+ path = output_file_from_url(item.get("url") or "")
+ if not path:
+ return reference_id, {"status": "skipped", "reason": "参考图不是本机素材"}
+ prompt = ecommerce_universal_analysis_prompt(item)
+ try:
+ cache_key = ecommerce_vision_cache_key(path, prompt, route)
+ except OSError:
+ return reference_id, {"status": "skipped", "reason": "参考图文件不存在"}
+ cached = get_cached_ecommerce_vision_analysis(cache_key)
+ if cached is not None:
+ cached["cached"] = True
+ return reference_id, cached
+ try:
+ async with ECOMMERCE_VISION_SEMAPHORE:
+ cached = get_cached_ecommerce_vision_analysis(cache_key)
+ if cached is not None:
+ cached["cached"] = True
+ return reference_id, cached
+ text, resolved_model = await caption_image_with_provider(
+ path,
+ prompt,
+ route["provider_id"],
+ route["model"],
+ )
+ analysis = parse_ecommerce_universal_reference_analysis(text)
+ analysis.update({
+ "status": "succeeded" if analysis.get("item_name") or analysis.get("category") else "unrecognized",
+ "provider_id": route["provider_id"],
+ "provider_name": route["provider_name"],
+ "model": resolved_model,
+ })
+ cache_ecommerce_vision_analysis(cache_key, analysis)
+ return reference_id, analysis
+ except Exception as exc:
+ return reference_id, {"status": "failed", "error": str(exc)[:240]}
+
+async def analyze_ecommerce_universal_references(inputs: List[Dict[str, Any]]) -> Dict[str, Any]:
+ route = configured_ecommerce_vision_route()
+ if not route:
+ return {"status": "skipped", "reason": "未配置可用视觉模型", "items": {}}
+ analyzed = await asyncio.gather(*(
+ analyze_ecommerce_universal_reference(index, item, route)
+ for index, item in enumerate(inputs)
+ ))
+ items = dict(analyzed)
+ succeeded = sum(1 for analysis in items.values() if analysis.get("status") == "succeeded")
+ return {
+ "status": "succeeded" if succeeded else "failed",
+ "provider_id": route["provider_id"],
+ "provider_name": route["provider_name"],
+ "model": route["model"],
+ "succeeded": succeeded,
+ "total": len(inputs),
+ "items": items,
+ }
+
+async def enrich_ecommerce_snapshot_with_garment_analysis(snapshot: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
+ working = {
+ **snapshot,
+ "inputs": [dict(item) for item in (snapshot.get("inputs") or [])],
+ "options": dict(snapshot.get("options") or {}),
+ }
+ if working.get("operation") != "try_on" or str(working["options"].get("garment_category") or "auto") != "auto":
+ return working, None
+ analysis = await analyze_ecommerce_garment(working["inputs"])
+ if analysis.get("category") in {"upper", "lower", "dress"}:
+ working["options"]["garment_category"] = analysis["category"]
+ working["options"]["garment_type"] = analysis.get("garment_type") or ""
+ working["prompt"] = build_ecommerce_prompt(working["operation"], working["inputs"], working["options"])
+ return working, analysis
+
+async def enrich_ecommerce_snapshot_with_universal_analysis(snapshot: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
+ working = {
+ **snapshot,
+ "inputs": [dict(item) for item in (snapshot.get("inputs") or [])],
+ "options": dict(snapshot.get("options") or {}),
+ }
+ if working.get("operation") != "universal":
+ return working, None
+ analysis = await analyze_ecommerce_universal_references(working["inputs"])
+ items = analysis.get("items") if isinstance(analysis, dict) else {}
+ if isinstance(items, dict) and any((value or {}).get("status") == "succeeded" for value in items.values() if isinstance(value, dict)):
+ working["options"]["reference_analysis"] = items
+ working["prompt"] = build_ecommerce_prompt(working["operation"], working["inputs"], working["options"])
+ return working, analysis
+
+def validate_ecommerce_local_inputs(inputs: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], Tuple[int, int]]:
+ checked = []
+ dimensions = {}
+ for item in inputs:
+ role = str(item.get("role") or "").strip().lower()
+ reference_type = str(item.get("reference_type") or role).strip().lower()
+ url = str(item.get("url") or "").strip()
+ path = output_file_from_url(url)
+ if not path:
+ raise HTTPException(status_code=400, detail=f"{role} 输入必须是已上传到本机素材目录的图片")
+ ext = os.path.splitext(path)[1].lower()
+ if ext not in ECOMMERCE_INPUT_EXTS:
+ raise HTTPException(status_code=400, detail=f"{role} 仅支持 PNG、JPEG 或 WebP")
+ try:
+ size_bytes = os.path.getsize(path)
+ if size_bytes > ECOMMERCE_INPUT_MAX_BYTES:
+ raise HTTPException(status_code=400, detail=f"{role} 文件不能超过 50MB")
+ with Image.open(path) as image:
+ width, height = image.size
+ image_format = str(image.format or "").upper()
+ if image_format not in ECOMMERCE_INPUT_FORMAT_MIMES or width < 1 or height < 1:
+ raise ValueError("unsupported image")
+ image.verify()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(status_code=400, detail=f"{role} 不是有效的 PNG、JPEG 或 WebP 图片") from exc
+ dimension_key = str(item.get("reference_id") or role)
+ dimensions[dimension_key] = (width, height)
+ if reference_type in {"source", "subject"} and "source" not in dimensions:
+ dimensions["source"] = (width, height)
+ normalized = {
+ "role": role,
+ "url": url,
+ "name": str(item.get("name") or os.path.basename(path))[:240],
+ "kind": "image",
+ "mime": ECOMMERCE_INPUT_FORMAT_MIMES[image_format],
+ }
+ for key in ("reference_id", "reference_type", "label", "instruction"):
+ if item.get(key):
+ normalized[key] = item.get(key)
+ checked.append(normalized)
+ source_dimensions = dimensions.get("source")
+ if not source_dimensions:
+ raise HTTPException(status_code=400, detail="缺少源图尺寸信息")
+ if dimensions.get("mask") and dimensions["mask"] != source_dimensions:
+ raise HTTPException(status_code=400, detail="蒙版尺寸必须与原图完全一致")
+ return checked, source_dimensions
+
+def prepare_ecommerce_request(payload: EcommerceTaskRequest) -> Dict[str, Any]:
+ try:
+ operation = validate_ecommerce_operation(payload.operation)
+ mode = validate_ecommerce_mode(payload.mode)
+ options_json = json.dumps(payload.options or {}, ensure_ascii=False)
+ if len(options_json.encode("utf-8")) > 20 * 1024:
+ raise ValueError("功能参数过大")
+ options = json.loads(options_json)
+ normalized = validate_ecommerce_input_roles(
+ operation,
+ [item.dict() for item in payload.inputs],
+ options,
+ )
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ inputs, source_dimensions = validate_ecommerce_local_inputs(normalized)
+ providers = configured_ecommerce_providers()
+ catalog = build_ecommerce_model_catalog(providers)
+ try:
+ candidates = ecommerce_route_candidates(catalog, mode, payload.provider_id, payload.model)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ reference_count = len(inputs)
+ candidates = [route for route in candidates if int(route.get("max_reference_images") or 0) >= reference_count]
+ if not candidates:
+ if operation == "universal":
+ raise HTTPException(status_code=400, detail=f"所选模型无法同时处理 {reference_count} 张参考图,请减少图片或选择支持更多参考图的 Gemini 3 模型")
+ raise HTTPException(status_code=400, detail="没有找到兼容的图片编辑模型,请检查已启用平台和模型配置")
+ if payload.model:
+ candidates = candidates[:1]
+ parent_task_id = str(payload.parent_task_id or "").strip()
+ if parent_task_id:
+ with ECOMMERCE_TASK_LOCK:
+ parent = dict(ECOMMERCE_TASKS.get(parent_task_id) or {})
+ if not parent:
+ raise HTTPException(status_code=400, detail="父任务不存在,无法创建新版本")
+ if parent.get("operation") != operation:
+ raise HTTPException(status_code=400, detail="新版本必须与父任务使用相同功能")
+ try:
+ generation = resolve_ecommerce_generation_settings(
+ source_dimensions[0],
+ source_dimensions[1],
+ mode,
+ payload.aspect_ratio,
+ payload.resolution,
+ payload.quality,
+ payload.count,
+ )
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ prompt = build_ecommerce_prompt(operation, inputs, options)
+ return {
+ "operation": operation,
+ "mode": mode,
+ "inputs": inputs,
+ "options": options,
+ "provider_id": str(payload.provider_id or "").strip().lower(),
+ "model": str(payload.model or "").strip(),
+ "parent_task_id": parent_task_id,
+ "source_dimensions": {"width": source_dimensions[0], "height": source_dimensions[1]},
+ **generation,
+ "prompt": prompt,
+ "route_candidates": [public_ecommerce_route(route) for route in candidates],
+ }
+
+async def run_ecommerce_task(task_id: str, snapshot: Dict[str, Any]):
+ async with ECOMMERCE_TASK_SEMAPHORE:
+ await execute_ecommerce_task(task_id, snapshot)
+
+async def execute_ecommerce_task(task_id: str, snapshot: Dict[str, Any]):
+ update_ecommerce_task(task_id, {"status": "running", "error": ""})
+ snapshot, garment_analysis = await enrich_ecommerce_snapshot_with_garment_analysis(snapshot)
+ snapshot, universal_analysis = await enrich_ecommerce_snapshot_with_universal_analysis(snapshot)
+ if garment_analysis is not None or universal_analysis is not None:
+ update_ecommerce_task(task_id, {
+ "options": snapshot["options"],
+ "prompt": snapshot["prompt"],
+ "garment_analysis": garment_analysis,
+ "universal_analysis": universal_analysis,
+ "request": snapshot,
+ })
+ routes = list(snapshot.get("route_candidates") or [])
+ failures = []
+ try:
+ for index, route in enumerate(routes):
+ try:
+ batch = await execute_ai_image_batch(
+ prompt=snapshot["prompt"],
+ provider_id=route["provider_id"],
+ model=route["model"],
+ size=snapshot["size"],
+ quality=snapshot["quality"],
+ references=snapshot["inputs"],
+ count=snapshot["count"],
+ prefix="ecommerce_",
+ allow_edit_endpoint_fallback=False,
+ semantic_mask=True,
+ )
+ raw = batch["raw"]
+ result = {
+ "type": "ecommerce",
+ "operation": snapshot["operation"],
+ "mode": snapshot["mode"],
+ "ecommerce_task_id": task_id,
+ "parent_task_id": snapshot.get("parent_task_id") or "",
+ "prompt": snapshot["prompt"],
+ "inputs": snapshot["inputs"],
+ "images": batch["images"],
+ "image_items": batch["image_items"],
+ "timestamp": batch["generation_completed_at"],
+ "provider_id": batch["provider"]["id"],
+ "provider_name": batch["provider"].get("name") or batch["provider"]["id"],
+ "model": batch["model"],
+ "size": snapshot["size"],
+ "aspect_ratio": snapshot["aspect_ratio"],
+ "resolution": snapshot["resolution"],
+ "quality": snapshot["quality"],
+ "candidate_count": len(batch["images"]),
+ "garment_analysis": garment_analysis,
+ "universal_analysis": universal_analysis,
+ "upstream_task_id": extract_task_id(raw) if isinstance(raw, dict) else None,
+ "request_id": raw.get("id") if isinstance(raw, dict) else None,
+ "generation_started_at": batch["generation_started_at"],
+ "generation_completed_at": batch["generation_completed_at"],
+ "generation_elapsed_seconds": batch["generation_elapsed_seconds"],
+ "raw_usage": raw.get("usage") if isinstance(raw, dict) else None,
+ "params": {
+ "operation": snapshot["operation"],
+ "mode": snapshot["mode"],
+ "options": snapshot["options"],
+ "provider_id": batch["provider"]["id"],
+ "model": batch["model"],
+ "size": snapshot["size"],
+ "aspect_ratio": snapshot["aspect_ratio"],
+ "resolution": snapshot["resolution"],
+ "quality": snapshot["quality"],
+ "n": snapshot["count"],
+ "parameters": snapshot["parameters"],
+ "reference_images": snapshot["inputs"],
+ },
+ }
+ save_to_history(result)
+ if GLOBAL_LOOP:
+ asyncio.run_coroutine_threadsafe(manager.broadcast_new_image(result), GLOBAL_LOOP)
+ update_ecommerce_task(task_id, {
+ "status": "succeeded",
+ "result": result,
+ "error": "",
+ "provider_id": result["provider_id"],
+ "provider_name": result["provider_name"],
+ "model": result["model"],
+ "route_attempts": failures + [{"route": public_ecommerce_route(route), "status": "succeeded"}],
+ })
+ return
+ except Exception as exc:
+ detail = str(getattr(exc, "detail", None) or exc)
+ status_code = int(getattr(exc, "status_code", 500) or 500)
+ failures.append({
+ "route": public_ecommerce_route(route),
+ "status": "failed",
+ "status_code": status_code,
+ "error": detail[:500],
+ })
+ can_fallback = index < len(routes) - 1 and ecommerce_safe_fallback_error(status_code, detail)
+ if can_fallback:
+ continue
+ raise
+ except Exception as exc:
+ detail = str(getattr(exc, "detail", None) or exc)
+ update_ecommerce_task(task_id, {
+ "status": "failed",
+ "error": detail,
+ "status_code": int(getattr(exc, "status_code", 500) or 500),
+ "route_attempts": failures,
+ "upstream_task_id": getattr(exc, "upstream_task_id", "") or extract_task_id_from_text(detail),
+ })
+
+def configured_ecommerce_providers() -> List[Dict[str, Any]]:
+ return [
+ provider for provider in load_api_providers()
+ if provider.get("enabled", True) and bool(provider_env_key_value(provider.get("id") or ""))
+ ]
+
+@app.get("/api/ecommerce/capabilities")
+async def get_ecommerce_capabilities():
+ return ecommerce_public_capabilities(configured_ecommerce_providers())
+
+@app.post("/api/ecommerce/tasks")
+async def create_ecommerce_task(payload: EcommerceTaskRequest):
+ snapshot = prepare_ecommerce_request(payload)
+ task_id = f"ecommerce_{uuid.uuid4().hex}"
+ now = time.time()
+ task = {
+ "id": task_id,
+ "task_id": task_id,
+ "type": "ecommerce",
+ "status": "queued",
+ "created_at": now,
+ "updated_at": now,
+ "result": None,
+ "error": "",
+ "approval": {"status": "pending", "output_index": None, "checks": {}, "note": ""},
+ **snapshot,
+ "request": snapshot,
+ }
+ with ECOMMERCE_TASK_LOCK:
+ ECOMMERCE_TASKS[task_id] = task
+ write_ecommerce_task_locked(task)
+ if len(ECOMMERCE_TASKS) > 5000:
+ removable = sorted(
+ (item for item in ECOMMERCE_TASKS.values() if item.get("status") not in ECOMMERCE_ACTIVE_STATUSES),
+ key=lambda item: float(item.get("updated_at") or 0),
+ )[:max(0, len(ECOMMERCE_TASKS) - 5000)]
+ for item in removable:
+ ECOMMERCE_TASKS.pop(str(item.get("id") or ""), None)
+ DATABASE.prune_tasks("ecommerce", 5000)
+ asyncio.create_task(run_ecommerce_task(task_id, snapshot))
+ return public_ecommerce_task(task)
+
+@app.get("/api/ecommerce/tasks")
+async def list_ecommerce_tasks(limit: int = 50, operation: str = ""):
+ safe_limit = max(1, min(2000, int(limit or 50)))
+ operation = str(operation or "").strip().lower()
+ if operation:
+ try:
+ validate_ecommerce_operation(operation)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ with ECOMMERCE_TASK_LOCK:
+ tasks = sorted(
+ (
+ public_ecommerce_task(task)
+ for task in ECOMMERCE_TASKS.values()
+ if not operation or task.get("operation") == operation
+ ),
+ key=lambda item: float(item.get("created_at") or 0),
+ reverse=True,
+ )[:safe_limit]
+ return {"tasks": tasks}
+
+@app.post("/api/ecommerce/tasks/status")
+async def ecommerce_task_status(payload: EcommerceTaskStatusRequest):
+ ids = list(dict.fromkeys(str(item or "").strip() for item in payload.ids if str(item or "").strip()))[:2000]
+ tasks = []
+ missing = []
+ with ECOMMERCE_TASK_LOCK:
+ for task_id in ids:
+ task = ECOMMERCE_TASKS.get(task_id)
+ if not task:
+ missing.append(task_id)
+ continue
+ tasks.append({
+ "id": task_id,
+ "task_id": task_id,
+ "status": str(task.get("status") or "queued"),
+ "updated_at": float(task.get("updated_at") or 0),
+ "error": str(task.get("error") or "")[:500],
+ })
+ return {"tasks": tasks, "missing": missing}
+
+@app.get("/api/ecommerce/tasks/{task_id}")
+async def get_ecommerce_task(task_id: str):
+ with ECOMMERCE_TASK_LOCK:
+ task = public_ecommerce_task(ECOMMERCE_TASKS.get(task_id) or {})
+ if not task.get("id"):
+ raise HTTPException(status_code=404, detail="电商任务不存在或已过期")
+ return task
+
+@app.post("/api/ecommerce/tasks/{task_id}/approve")
+async def approve_ecommerce_task(task_id: str, payload: EcommerceApprovalRequest):
+ with ECOMMERCE_TASK_LOCK:
+ task = dict(ECOMMERCE_TASKS.get(task_id) or {})
+ if not task:
+ raise HTTPException(status_code=404, detail="电商任务不存在或已过期")
+ if task.get("status") != "succeeded" or not isinstance(task.get("result"), dict):
+ raise HTTPException(status_code=409, detail="只有生成成功的任务可以审核")
+ images = list(task["result"].get("images") or [])
+ output_index = int(payload.output_index)
+ if output_index < 0 or output_index >= len(images):
+ raise HTTPException(status_code=400, detail="所选候选不存在")
+ required_checks = ECOMMERCE_QUALITY_CHECKS.get(task.get("operation") or "", [])
+ missing = [item["id"] for item in required_checks if payload.checks.get(item["id"]) is not True]
+ if missing:
+ raise HTTPException(status_code=400, detail="必须完成全部人工质量检查后才能标记为上架成片")
+ approval = {
+ "status": "approved",
+ "output_index": output_index,
+ "output_url": images[output_index],
+ "checks": {item["id"]: True for item in required_checks},
+ "note": str(payload.note or "").strip(),
+ "approved_at": time.time(),
+ "export": None,
+ }
+ update_ecommerce_task(task_id, {"approval": approval})
+ return {"task_id": task_id, "approval": approval}
+
+@app.post("/api/ecommerce/tasks/{task_id}/export")
+async def export_ecommerce_task(task_id: str):
+ with ECOMMERCE_TASK_LOCK:
+ task = dict(ECOMMERCE_TASKS.get(task_id) or {})
+ if not task:
+ raise HTTPException(status_code=404, detail="电商任务不存在或已过期")
+ approval = dict(task.get("approval") or {})
+ if approval.get("status") != "approved" or not approval.get("output_url"):
+ raise HTTPException(status_code=409, detail="候选尚未通过完整人工审核,不能导出上架成片")
+ existing = dict(approval.get("export") or {})
+ if existing.get("url") and output_file_from_url(existing["url"]):
+ return {"task_id": task_id, "export": existing}
+ source = output_file_from_url(approval["output_url"])
+ if not source:
+ raise HTTPException(status_code=404, detail="已审核候选文件不存在")
+ extension = os.path.splitext(source)[1].lower()
+ if extension not in ECOMMERCE_INPUT_EXTS:
+ extension = ".png"
+ date_part = datetime.datetime.now().strftime("%Y-%m-%d")
+ export_dir = os.path.abspath(os.path.join(OUTPUT_DIR, "ecommerce", date_part))
+ output_root = os.path.abspath(OUTPUT_DIR)
+ if os.path.commonpath([output_root, export_dir]) != output_root:
+ raise HTTPException(status_code=500, detail="导出目录校验失败")
+ os.makedirs(export_dir, exist_ok=True)
+ filename = f"ecommerce_{task.get('operation')}_{datetime.datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:8]}{extension}"
+ destination = os.path.join(export_dir, filename)
+ await asyncio.to_thread(shutil.copy2, source, destination)
+ export_info = {
+ "url": media_url_from_path(destination),
+ "name": filename,
+ "exported_at": time.time(),
+ "kind": "official",
+ }
+ approval["export"] = export_info
+ update_ecommerce_task(task_id, {"approval": approval})
+ return {"task_id": task_id, "export": export_info}
+
@app.post("/api/image-task-query")
async def query_image_task(payload: ImageTaskQueryRequest):
provider = get_api_provider(payload.provider_id)
@@ -11170,71 +12634,20 @@ async def create_canvas_image_task(payload: OnlineImageRequest):
"result": None,
"error": "",
"provider_id": payload.provider_id,
- "model": payload.model,
- }
- asyncio.create_task(run_canvas_image_task(task_id, payload))
- return {"task_id": task_id, "status": "queued"}
-
-@app.get("/api/canvas-image-tasks/{task_id}")
-async def get_canvas_image_task(task_id: str):
- with CANVAS_TASK_LOCK:
- task = dict(CANVAS_TASKS.get(task_id) or {})
- if not task:
- raise HTTPException(status_code=404, detail="画布任务不存在,可能服务已重启或任务已过期")
- return task
-
-async def run_canvas_comfy_task(task_id: str, payload: GenerateRequest):
- with CANVAS_TASK_LOCK:
- if task_id in CANVAS_TASKS:
- CANVAS_TASKS[task_id]["status"] = "running"
- CANVAS_TASKS[task_id]["updated_at"] = time.time()
- try:
- result = await asyncio.to_thread(generate, payload)
- if isinstance(result, dict) and result.get("error"):
- raise RuntimeError(str(result.get("error") or "ComfyUI 生成失败"))
- with CANVAS_TASK_LOCK:
- CANVAS_TASKS[task_id].update({
- "status": "succeeded",
- "result": result,
- "error": "",
- "updated_at": time.time(),
- })
- except Exception as exc:
- detail = getattr(exc, "detail", None) or str(exc)
- status_code = getattr(exc, "status_code", 500)
- with CANVAS_TASK_LOCK:
- CANVAS_TASKS[task_id].update({
- "status": "failed",
- "error": str(detail),
- "status_code": status_code,
- "updated_at": time.time(),
- })
-
-@app.post("/api/canvas-comfy-tasks")
-async def create_canvas_comfy_task(payload: GenerateRequest):
- task_id = f"canvas_comfy_{uuid.uuid4().hex}"
- with CANVAS_TASK_LOCK:
- CANVAS_TASKS[task_id] = {
- "id": task_id,
- "type": "comfy",
- "status": "queued",
- "created_at": time.time(),
- "updated_at": time.time(),
- "result": None,
- "error": "",
- "workflow_json": payload.workflow_json,
+ "model": payload.model,
}
- asyncio.create_task(run_canvas_comfy_task(task_id, payload))
+ asyncio.create_task(run_canvas_image_task(task_id, payload))
return {"task_id": task_id, "status": "queued"}
-@app.get("/api/canvas-comfy-tasks/{task_id}")
-async def get_canvas_comfy_task(task_id: str):
+@app.get("/api/canvas-image-tasks/{task_id}")
+async def get_canvas_image_task(task_id: str):
with CANVAS_TASK_LOCK:
task = dict(CANVAS_TASKS.get(task_id) or {})
if not task:
- raise HTTPException(status_code=404, detail="ComfyUI 任务不存在,可能服务已重启或任务已过期")
+ raise HTTPException(status_code=404, detail="画布任务不存在,可能服务已重启或任务已过期")
return task
+
# --- 图像生成参数 schema(供客户端动态渲染参数表单,避免把参数写死在前端) ---
IMAGE_PARAM_RATIOS = [
{"value": "1:1", "label": "1:1"},
@@ -12298,9 +13711,9 @@ async def get_conversation(conversation_id: str, request: Request, x_user_id: st
@app.delete("/api/conversations/{conversation_id}")
async def delete_conversation(conversation_id: str, request: Request, x_user_id: str = Header(default="")):
user_id = safe_user_id(x_user_id, request)
- path = conversation_path(user_id, conversation_id)
- if os.path.exists(path):
- os.remove(path)
+ cleaned = conversation_path(user_id, conversation_id)
+ DATABASE.delete_conversation(user_id, cleaned)
+ publish_entity_changed("session", cleaned)
return {"ok": True}
# --- 画布管理 ---
@@ -12342,22 +13755,10 @@ async def delete_project(project_id: str):
projects = [p for p in projects if p.get("id") != project_id]
save_projects(projects)
# 把该项目下的画布迁回默认项目
- moved = 0
with CANVAS_LOCK:
- for filename in os.listdir(CANVAS_DIR):
- if not filename.endswith(".json"):
- continue
- path = os.path.join(CANVAS_DIR, filename)
- try:
- with open(path, 'r', encoding='utf-8') as f:
- data = json.load(f)
- except Exception:
- continue
- if str(data.get("project") or "") == project_id:
- data["project"] = DEFAULT_PROJECT_ID
- with open(path, 'w', encoding='utf-8') as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
- moved += 1
+ moved = DATABASE.reassign_project(project_id, DEFAULT_PROJECT_ID)
+ if moved:
+ publish_entity_changed("canvas", "global")
return {"ok": True, "moved": moved}
@app.get("/api/canvases/trash")
@@ -12401,8 +13802,8 @@ async def update_canvas_meta(canvas_id: str, payload: CanvasMetaUpdate):
if payload.board_y is not None:
canvas["board_y"] = float(payload.board_y)
with CANVAS_LOCK:
- with open(canvas_path(canvas["id"]), 'w', encoding='utf-8') as f:
- json.dump(canvas, f, ensure_ascii=False, indent=2)
+ DATABASE.save_canvas(canvas, touch=False)
+ publish_entity_changed("canvas", canvas_id, int(canvas.get("revision") or 0), updated_at=int(canvas.get("updated_at") or 0))
return {"canvas": canvas_record(canvas)}
@app.get("/api/canvases/{canvas_id}")
@@ -13495,11 +14896,15 @@ async def batch_crop_asset_library_items(payload: AssetLibraryBatchCropRequest):
async def update_canvas(canvas_id: str, payload: CanvasSaveRequest):
canvas = load_canvas(canvas_id)
current_updated_at = int(canvas.get("updated_at") or 0)
- if payload.base_updated_at and current_updated_at and int(payload.base_updated_at) < current_updated_at:
+ current_revision = int(canvas.get("revision") or 0)
+ revision_conflict = payload.base_revision and current_revision and int(payload.base_revision) != current_revision
+ timestamp_conflict = payload.base_updated_at and current_updated_at and int(payload.base_updated_at) < current_updated_at
+ if revision_conflict or timestamp_conflict:
raise HTTPException(status_code=409, detail={
"message": "画布已被其他页面更新,已拒绝旧版本覆盖。",
"canvas": canvas,
"updated_at": current_updated_at,
+ "revision": current_revision,
})
canvas["title"] = (payload.title or canvas.get("title") or "未命名画布")[:80]
canvas["icon"] = (payload.icon or canvas.get("icon") or "layers")[:32]
@@ -13512,8 +14917,7 @@ async def update_canvas(canvas_id: str, payload: CanvasSaveRequest):
canvas["viewport"] = canvas.get("viewport") or {"x": 0, "y": 0, "scale": 1}
canvas["logs"] = payload.logs[-500:]
canvas["settings"] = payload.settings or {}
- save_canvas(canvas)
- await manager.broadcast_canvas_updated(canvas_id, int(canvas.get("updated_at") or now_ms()), payload.client_id)
+ save_canvas(canvas, payload.client_id)
return {"canvas": canvas}
@app.delete("/api/canvases/{canvas_id}")
@@ -13534,9 +14938,8 @@ async def restore_canvas(canvas_id: str):
@app.delete("/api/canvases/{canvas_id}/purge")
async def purge_canvas(canvas_id: str):
- path = canvas_path(canvas_id)
- if os.path.exists(path):
- os.remove(path)
+ DATABASE.purge_canvas(canvas_path(canvas_id))
+ publish_entity_changed("canvas", canvas_id)
return {"ok": True}
# --- GPT 对话 ---
@@ -13594,6 +14997,24 @@ async def chat(payload: ChatRequest, request: Request, x_user_id: str = Header(d
"raw_usage": raw.get("usage") if isinstance(raw, dict) else None,
}
else:
+ _codex_provider = get_api_provider(payload.provider)
+ if is_codex_provider(_codex_provider):
+ model = selected_model(payload.model, (_codex_provider.get("chat_models") or CODEX_DEFAULT_CHAT_MODELS)[0])
+ payload.model = model
+ text, raw = await codex_chat_text(payload, conversation["messages"][-MAX_HISTORY_MESSAGES:])
+ assistant_message = {
+ "id": uuid.uuid4().hex,
+ "role": "assistant",
+ "content": text,
+ "created_at": now_ms(),
+ "model": model,
+ "raw_usage": None,
+ "raw": raw,
+ }
+ conversation["messages"].append(assistant_message)
+ conversation["updated_at"] = now_ms()
+ save_conversation(user_id, conversation)
+ return {"conversation": conversation, "message": assistant_message}
chat_base, chat_hdrs, model = resolve_chat_provider(payload.provider, payload.model, payload.ms_model)
_conv_provider = get_api_provider(payload.provider) if payload.provider not in ("modelscope",) else {}
_conv_is_apimart = is_apimart_provider(_conv_provider)
@@ -13749,6 +15170,35 @@ async def chat_stream(payload: ChatRequest, request: Request, x_user_id: str = H
conversation["updated_at"] = now_ms()
save_conversation(user_id, conversation)
+ _codex_provider = get_api_provider(payload.provider)
+ if is_codex_provider(_codex_provider):
+ model = selected_model(payload.model, (_codex_provider.get("chat_models") or CODEX_DEFAULT_CHAT_MODELS)[0])
+ payload.model = model
+
+ async def codex_stream():
+ yield sse_event({"type": "meta", "conversation": conversation})
+ try:
+ text, raw = await codex_chat_text(payload, conversation["messages"][-MAX_HISTORY_MESSAGES:])
+ except HTTPException as exc:
+ yield sse_event({"type": "error", "detail": exc.detail})
+ return
+ assistant_message = {
+ "id": uuid.uuid4().hex,
+ "role": "assistant",
+ "content": text,
+ "created_at": now_ms(),
+ "model": model,
+ "raw_usage": None,
+ "raw": raw,
+ }
+ conversation["messages"].append(assistant_message)
+ conversation["updated_at"] = now_ms()
+ save_conversation(user_id, conversation)
+ yield sse_event({"type": "delta", "delta": text})
+ yield sse_event({"type": "done", "conversation": conversation, "message": assistant_message})
+
+ return StreamingResponse(codex_stream(), media_type="text/event-stream")
+
chat_base, chat_hdrs, model = resolve_chat_provider(payload.provider, payload.model, payload.ms_model)
_stream_provider = get_api_provider(payload.provider) if payload.provider not in ("modelscope",) else {}
history = conversation["messages"][-MAX_HISTORY_MESSAGES:]
@@ -13815,726 +15265,310 @@ async def stream():
# --- 历史记录 ---
-@app.get("/api/history")
-async def get_history_api(type: str = None):
- if os.path.exists(HISTORY_FILE):
- try:
- with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
- data = json.load(f)
- if type:
- data = [item for item in data if item.get("type", "zimage") == type]
- data = [item for item in data if item.get("images") and len(item["images"]) > 0]
-
- def sort_key(item):
- ts = item.get("timestamp", 0)
- if isinstance(ts, (int, float)):
- return float(ts)
- return 0
-
- data.sort(key=sort_key, reverse=True)
- return data
- except Exception as e:
- print(f"读取历史文件失败: {e}")
- return []
- return []
-
-@app.get("/api/queue_status")
-async def get_queue_status(client_id: str):
- with QUEUE_LOCK:
- total = len(QUEUE)
- positions = [i + 1 for i, t in enumerate(QUEUE) if t["client_id"] == client_id]
- position = positions[0] if positions else 0
- return {"total": total, "position": position}
-
-@app.post("/api/history/delete")
-async def delete_history(req: DeleteHistoryRequest):
- if not os.path.exists(HISTORY_FILE):
- return {"success": False, "message": "History file not found"}
- try:
- with HISTORY_LOCK:
- with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
- history = json.load(f)
- target_record = None
- new_history = []
- for item in history:
- is_match = False
- item_ts = item.get("timestamp", 0)
- if isinstance(req.timestamp, (int, float)) and isinstance(item_ts, (int, float)):
- if abs(float(item_ts) - float(req.timestamp)) < 0.001:
- is_match = True
- elif str(item_ts) == str(req.timestamp):
- is_match = True
- if is_match:
- target_record = item
- else:
- new_history.append(item)
- if target_record:
- with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
- json.dump(new_history, f, ensure_ascii=False, indent=4)
-
- if target_record:
- for img_url in target_record.get("images", []):
- file_path = output_file_from_url(img_url)
- if file_path and os.path.exists(file_path):
- try:
- os.remove(file_path)
- except Exception as e:
- print(f"Failed to delete file {file_path}: {e}")
- return {"success": True}
- else:
- return {"success": False, "message": "Record not found"}
- except Exception as e:
- print(f"Delete history error: {e}")
- return {"success": False, "message": str(e)}
-
-# --- ModelScope 角度控制 ---
-
-@app.post("/api/angle/poll_status")
-async def poll_angle_cloud(req: CloudPollRequest):
- api_root = modelscope_image_api_root()
- clean_token = modelscope_api_key(req.api_key)
- if not clean_token:
- raise HTTPException(status_code=400, detail="未提供 ModelScope API Key")
-
- headers = {
- "Authorization": f"Bearer {clean_token}",
- "Content-Type": "application/json",
- "X-ModelScope-Async-Mode": "true"
- }
- task_id = req.task_id
- print(f"Resuming polling for Angle Task: {task_id}")
-
- try:
- async with httpx.AsyncClient(timeout=30) as client:
- for i in range(300):
- await asyncio.sleep(2)
- result = await client.get(
- f"{api_root}/tasks/{task_id}",
- headers={**headers, "X-ModelScope-Task-Type": "image_generation"},
- )
- result.raise_for_status()
- data = result.json()
- status = str(data.get("task_status") or "").upper()
-
- if status == "SUCCEED":
- img_url = data["output_images"][0]
- local_path = ""
- try:
- async with httpx.AsyncClient() as dl_client:
- img_res = await dl_client.get(img_url)
- if img_res.status_code == 200:
- filename = f"cloud_angle_{int(time.time())}.png"
- file_path = output_path_for(filename, "output")
- with open(file_path, "wb") as f:
- f.write(img_res.content)
- local_path = output_url_for(filename, "output")
- else:
- local_path = img_url
- except Exception:
- local_path = img_url
-
- record = {"timestamp": time.time(), "prompt": f"Resumed {task_id}", "images": [local_path], "type": "angle"}
- save_to_history(record)
- if req.client_id:
- await manager.send_personal_message({"type": "cloud_status", "status": "SUCCEED", "task_id": task_id}, req.client_id)
- return {"url": local_path}
-
- elif status in {"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED", "TIMEOUT", "REVOKED"}:
- if req.client_id:
- await manager.send_personal_message({"type": "cloud_status", "status": "FAILED", "task_id": task_id}, req.client_id)
- raise HTTPException(status_code=502, detail=f"ModelScope task failed: {data}")
-
- if i % 5 == 0 and req.client_id:
- await manager.send_personal_message({
- "type": "cloud_status", "status": f"{status} ({i}/300)",
- "task_id": task_id, "progress": i, "total": 300
- }, req.client_id)
-
- if req.client_id:
- await manager.send_personal_message({"type": "cloud_status", "status": "TIMEOUT", "task_id": task_id}, req.client_id)
- return {"status": "timeout", "task_id": task_id, "message": "Task still pending"}
-
- except HTTPException:
- raise
- except Exception as e:
- print(f"Angle polling error: {e}")
- raise HTTPException(status_code=400, detail=str(e))
-
-@app.post("/api/angle/generate")
-async def generate_angle_cloud(req: CloudGenRequest):
- api_root = modelscope_image_api_root()
- clean_token = modelscope_api_key(req.api_key)
- if not clean_token:
- raise HTTPException(status_code=400, detail="未提供 ModelScope API Key")
-
- headers = {
- "Authorization": f"Bearer {clean_token}",
- "Content-Type": "application/json",
- "X-ModelScope-Async-Mode": "true"
- }
- model = selected_model(req.model, "Qwen/Qwen-Image-Edit-2511")
- payload = {
- "model": model,
- "prompt": req.prompt.strip(),
- "image_url": [modelscope_image_url(url, max_size=1536) for url in req.image_urls]
- }
- if req.resolution:
- payload["size"] = modelscope_size(req.resolution)
- if req.loras is not None:
- payload["loras"] = req.loras
-
+def online_history_number(value):
+ if value is None or value == "":
+ return None
try:
- async with httpx.AsyncClient(timeout=30) as client:
- submit_res = await client.post(f"{api_root}/images/generations", headers=headers, json=payload)
- if submit_res.status_code != 200:
- try:
- detail = submit_res.json()
- except:
- detail = submit_res.text
- raise HTTPException(status_code=submit_res.status_code, detail=detail)
-
- task_id = submit_res.json().get("task_id")
- print(f"Angle Task submitted, ID: {task_id}")
-
- for i in range(300):
- await asyncio.sleep(2)
- result = await client.get(
- f"{api_root}/tasks/{task_id}",
- headers={**headers, "X-ModelScope-Task-Type": "image_generation"},
- )
- result.raise_for_status()
- data = result.json()
- status = str(data.get("task_status") or "").upper()
-
- if status == "SUCCEED":
- img_url = data["output_images"][0]
- local_path = ""
- try:
- async with httpx.AsyncClient() as dl_client:
- img_res = await dl_client.get(img_url)
- if img_res.status_code == 200:
- filename = f"cloud_angle_{int(time.time())}.png"
- file_path = output_path_for(filename, "output")
- with open(file_path, "wb") as f:
- f.write(img_res.content)
- local_path = output_url_for(filename, "output")
- else:
- local_path = img_url
- except Exception:
- local_path = img_url
-
- record = {"timestamp": time.time(), "prompt": req.prompt, "images": [local_path], "type": "angle"}
- save_to_history(record)
- if req.client_id:
- await manager.send_personal_message({"type": "cloud_status", "status": "SUCCEED", "task_id": task_id}, req.client_id)
- if GLOBAL_LOOP:
- asyncio.run_coroutine_threadsafe(manager.broadcast_new_image(record), GLOBAL_LOOP)
- return {"url": local_path, "task_id": task_id}
-
- elif status in {"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED", "TIMEOUT", "REVOKED"}:
- if req.client_id:
- await manager.send_personal_message({"type": "cloud_status", "status": "FAILED", "task_id": task_id}, req.client_id)
- raise HTTPException(status_code=502, detail=f"ModelScope task failed: {data}")
-
- if i % 5 == 0 and req.client_id:
- await manager.send_personal_message({
- "type": "cloud_status", "status": f"{status} ({i}/300)",
- "task_id": task_id, "progress": i, "total": 300
- }, req.client_id)
-
- if req.client_id:
- await manager.send_personal_message({"type": "cloud_status", "status": "TIMEOUT", "task_id": task_id}, req.client_id)
- return {"status": "timeout", "task_id": task_id, "message": "Task still pending"}
-
- except HTTPException:
- raise
- except Exception as e:
- print(f"Angle generation error: {e}")
- raise HTTPException(status_code=400, detail=str(e))
+ return float(value)
+ except (TypeError, ValueError):
+ return None
-# --- ModelScope Z-Image 云端生图 ---
+def online_history_first_value(*values):
+ for value in values:
+ if value is not None and value != "":
+ return value
+ return None
-@app.post("/generate")
-async def generate_cloud(req: CloudGenRequest):
- api_root = modelscope_image_api_root()
- clean_token = modelscope_api_key(req.api_key)
- if not clean_token:
- raise HTTPException(status_code=400, detail="未提供 ModelScope API Key")
+def online_history_timestamp_key(value):
+ number = online_history_number(value)
+ if number is None:
+ return ""
+ return f"{number:.6f}"
- headers = {
- "Authorization": f"Bearer {clean_token}",
- "Content-Type": "application/json",
- }
- payload = {
- "model": "Tongyi-MAI/Z-Image-Turbo",
- "prompt": req.prompt.strip(),
- "size": modelscope_size(req.resolution),
- "n": 1
+def online_history_task_meta(task: Dict[str, Any]):
+ result = task.get("result") if isinstance(task, dict) else {}
+ if not isinstance(result, dict) or result.get("type") != "online":
+ return None
+ params = result.get("params") if isinstance(result.get("params"), dict) else {}
+ started_at = online_history_number(online_history_first_value(
+ result.get("generation_started_at"),
+ task.get("created_at"),
+ ))
+ completed_at = online_history_number(online_history_first_value(
+ result.get("generation_completed_at"),
+ result.get("timestamp"),
+ task.get("updated_at"),
+ ))
+ elapsed = online_history_number(online_history_first_value(
+ result.get("generation_elapsed_seconds"),
+ params.get("generation_elapsed_seconds"),
+ ))
+ if elapsed is None and started_at is not None and completed_at is not None:
+ elapsed = max(0, completed_at - started_at)
+ if elapsed is None:
+ return None
+ return {
+ "task_record_id": task.get("id") or task.get("task_id"),
+ "generation_started_at": started_at,
+ "generation_completed_at": completed_at,
+ "generation_elapsed_seconds": round(elapsed, 3),
}
- if req.loras is not None:
- payload["loras"] = req.loras
- try:
- async with httpx.AsyncClient(timeout=30) as client:
- submit_res = await client.post(
- f"{api_root}/images/generations",
- headers={**headers, "X-ModelScope-Async-Mode": "true"},
- json=payload
- )
- if submit_res.status_code != 200:
- try:
- detail = submit_res.json()
- except:
- detail = submit_res.text
- raise HTTPException(status_code=submit_res.status_code, detail=detail)
-
- task_id = submit_res.json().get("task_id")
- print(f"Z-Image Task submitted, ID: {task_id}")
-
- for i in range(200):
- await asyncio.sleep(3)
- result = await client.get(
- f"{api_root}/tasks/{task_id}",
- headers={**headers, "X-ModelScope-Task-Type": "image_generation"},
- )
- result.raise_for_status()
- data = result.json()
- status = str(data.get("task_status") or "").upper()
+def online_history_task_meta_index():
+ by_timestamp = {}
+ by_image = {}
+ with ONLINE_IMAGE_TASK_LOCK:
+ tasks = list(ONLINE_IMAGE_TASKS.values())
+ for task in tasks:
+ meta = online_history_task_meta(task)
+ if not meta:
+ continue
+ result = task.get("result") if isinstance(task, dict) else {}
+ timestamp_key = online_history_timestamp_key(result.get("timestamp"))
+ if timestamp_key:
+ by_timestamp[timestamp_key] = meta
+ for url in result.get("images") or []:
+ if url:
+ by_image[str(url)] = meta
+ return by_timestamp, by_image
+
+def merge_online_history_meta(item: Dict[str, Any], meta: Dict[str, Any] = None):
+ if not isinstance(item, dict) or item.get("type") != "online":
+ return item
+ params = item.get("params") if isinstance(item.get("params"), dict) else {}
+ current_elapsed = online_history_number(online_history_first_value(
+ item.get("generation_elapsed_seconds"),
+ params.get("generation_elapsed_seconds"),
+ ))
+ if not meta and current_elapsed is None:
+ return item
+ merged = dict(item)
+ if meta:
+ for key, value in meta.items():
+ if value is not None and merged.get(key) in (None, ""):
+ merged[key] = value
+ merged_params = dict(params)
+ elapsed_value = online_history_first_value(
+ merged.get("generation_elapsed_seconds"),
+ merged_params.get("generation_elapsed_seconds"),
+ )
+ if elapsed_value is not None and elapsed_value != "" and merged_params.get("generation_elapsed_seconds") in (None, ""):
+ merged_params["generation_elapsed_seconds"] = elapsed_value
+ if merged_params:
+ merged["params"] = merged_params
+ return merged
- if i % 5 == 0:
- print(f"Task {task_id} status check {i}: {status}")
+def enrich_online_history_generation_meta(items):
+ if not any(isinstance(item, dict) and item.get("type") == "online" for item in items):
+ return items
+ by_timestamp, by_image = online_history_task_meta_index()
+ enriched = []
+ for item in items:
+ if not isinstance(item, dict) or item.get("type") != "online":
+ enriched.append(item)
+ continue
+ meta = by_timestamp.get(online_history_timestamp_key(item.get("timestamp")))
+ if not meta:
+ for url in item.get("images") or []:
+ meta = by_image.get(str(url))
+ if meta:
+ break
+ enriched.append(merge_online_history_meta(item, meta))
+ return enriched
- if status == "SUCCEED":
- img_url = data["output_images"][0]
- local_path = ""
- try:
- async with httpx.AsyncClient() as dl_client:
- img_res = await dl_client.get(img_url)
- if img_res.status_code == 200:
- filename = f"cloud_{int(time.time())}.png"
- file_path = output_path_for(filename, "output")
- with open(file_path, "wb") as f:
- f.write(img_res.content)
- local_path = output_url_for(filename, "output")
- else:
- local_path = img_url
- except Exception as dl_e:
- print(f"Download error: {dl_e}")
- local_path = img_url
-
- record = {"timestamp": time.time(), "prompt": req.prompt, "images": [local_path], "type": "cloud"}
- save_to_history(record)
- try:
- await manager.broadcast_new_image(record)
- except Exception:
- pass
- return {"url": local_path}
+@app.get("/api/history")
+async def get_history_api(type: str = None):
+ data = DATABASE.list_history(type or "")
+ data = [item for item in data if item.get("images") and len(item["images"]) > 0]
+ return enrich_online_history_generation_meta(data)
- elif status in {"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED", "TIMEOUT", "REVOKED"}:
- raise HTTPException(status_code=502, detail=f"ModelScope task failed: {data}")
- raise Exception("Cloud generation timeout")
+WORK_METADATA_LOCK = Lock()
- except HTTPException:
- raise
- except Exception as e:
- print(f"Cloud generation error: {e}")
- raise HTTPException(status_code=400, detail=str(e))
-# --- ModelScope 通用图片生成(支持图生图) ---
+def work_metadata() -> Dict[str, Dict[str, Any]]:
+ value = DATABASE.get_document("works", "metadata", {})
+ return value if isinstance(value, dict) else {}
-@app.post("/api/ms/generate")
-async def ms_generate(req: MsGenerateRequest):
- api_root = modelscope_image_api_root()
- clean_token = modelscope_api_key(req.api_key)
- if not clean_token:
- raise HTTPException(status_code=400, detail="未配置 ModelScope API Key,请在 API 设置中填写,或重新保存 ModelScope Token。")
- headers = {
- "Authorization": f"Bearer {clean_token}",
- "Content-Type": "application/json",
- "X-ModelScope-Async-Mode": "true"
- }
- payload = {
- "model": req.model,
- "prompt": req.prompt.strip(),
- }
- if req.width and req.height:
- payload["width"] = req.width
- payload["height"] = req.height
- payload["size"] = modelscope_size(req.size or f"{req.width}x{req.height}")
- elif req.size:
- payload["size"] = modelscope_size(req.size)
- if req.image_urls:
- payload["image_url"] = [modelscope_image_url(url, max_size=1536) for url in req.image_urls]
- if req.loras is not None:
- payload["loras"] = req.loras
+def work_item_id(history_id: str, index: int, url: str) -> str:
+ identity = f"{history_id}\0{int(index)}\0{url}".encode("utf-8")
+ return "work_" + hashlib.sha256(identity).hexdigest()[:24]
- try:
- async with httpx.AsyncClient(timeout=30) as client:
- submit_res = await client.post(
- f"{api_root}/images/generations",
- headers=headers,
- json=payload
- )
- if submit_res.status_code != 200:
- try:
- detail = submit_res.json()
- except:
- detail = submit_res.text
- raise HTTPException(status_code=submit_res.status_code, detail=detail)
- task_id = submit_res.json().get("task_id")
- print(f"MS Generate Task submitted ({req.model}), ID: {task_id}")
+def history_reference_images(record: Dict[str, Any]) -> List[Dict[str, Any]]:
+ params = record.get("params") if isinstance(record.get("params"), dict) else {}
+ values = record.get("inputs") or params.get("reference_images") or record.get("reference_images") or []
+ return [dict(item) for item in values if isinstance(item, dict) and str(item.get("url") or "").strip()]
- TERMINAL_FAILED_STATUSES = {"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED", "TIMEOUT", "REVOKED"}
- for i in range(300):
- await asyncio.sleep(2)
- try:
- result = await client.get(
- f"{api_root}/tasks/{task_id}",
- headers={**headers, "X-ModelScope-Task-Type": "image_generation"},
- )
- data = result.json()
- status = data.get("task_status")
- print(f"MS Task {task_id} poll {i}: status={status}")
+def generated_work_items(records: List[Dict[str, Any]], metadata: Optional[Dict[str, Dict[str, Any]]] = None) -> List[Dict[str, Any]]:
+ metadata = metadata if isinstance(metadata, dict) else {}
+ works = []
+ for record in records:
+ if not isinstance(record, dict):
+ continue
+ history_id = str(record.get("_history_id") or record.get("id") or "").strip()
+ if not history_id:
+ continue
+ images = [str(url).strip() for url in record.get("images") or [] if str(url).strip()]
+ image_items = record.get("image_items") if isinstance(record.get("image_items"), list) else []
+ references = history_reference_images(record)
+ source = next((item for item in references if item.get("role") == "source"), references[0] if references else {})
+ created_at = float(record.get("timestamp") or record.get("created_at") or 0)
+ params = record.get("params") if isinstance(record.get("params"), dict) else {}
+ for index, url in enumerate(images):
+ item_meta = image_items[index] if index < len(image_items) and isinstance(image_items[index], dict) else {}
+ item_id = work_item_id(history_id, index, url)
+ saved_meta = metadata.get(item_id) if isinstance(metadata.get(item_id), dict) else {}
+ original_name = os.path.basename(urllib.parse.unquote(urllib.parse.urlparse(url).path)) or f"作品-{index + 1}"
+ name = str(saved_meta.get("name") or original_name).strip()[:160] or original_name
+ works.append({
+ "id": item_id,
+ "history_id": history_id,
+ "output_index": index,
+ "name": name,
+ "original_name": original_name,
+ "url": url,
+ "kind": str(record.get("type") or "image"),
+ "operation": str(record.get("operation") or params.get("operation") or ""),
+ "created_at": created_at,
+ "prompt": str(record.get("prompt") or params.get("prompt") or ""),
+ "provider_id": str(record.get("provider_id") or params.get("provider_id") or ""),
+ "provider_name": str(record.get("provider_name") or ""),
+ "model": str(record.get("model") or params.get("model") or ""),
+ "width": int(item_meta.get("width") or 0),
+ "height": int(item_meta.get("height") or 0),
+ "task_id": str(record.get("ecommerce_task_id") or record.get("task_id") or ""),
+ "source_url": str(source.get("url") or ""),
+ "references": references,
+ "favorite": bool(saved_meta.get("favorite")),
+ "favorite_updated_at": float(saved_meta.get("favorite_updated_at") or saved_meta.get("updated_at") or 0),
+ "trashed": bool(saved_meta.get("trashed")),
+ "trashed_at": float(saved_meta.get("trashed_at") or 0),
+ "metadata_updated_at": float(saved_meta.get("updated_at") or 0),
+ })
+ works.sort(key=lambda item: (float(item.get("created_at") or 0), item["id"]), reverse=True)
+ return works
+
+
+def all_generated_works() -> List[Dict[str, Any]]:
+ records = DATABASE.list_history("")
+ return generated_work_items(records, work_metadata())
+
+
+@app.get("/api/works")
+async def list_generated_works(favorite: Optional[bool] = None, kind: str = "", search: str = "", limit: int = 500, include_trashed: bool = False):
+ works = all_generated_works()
+ if not include_trashed:
+ works = [item for item in works if not item.get("trashed")]
+ normalized_kind = str(kind or "").strip().lower()
+ normalized_search = str(search or "").strip().lower()
+ if favorite is not None:
+ works = [item for item in works if bool(item.get("favorite")) is bool(favorite)]
+ if normalized_kind:
+ works = [item for item in works if str(item.get("kind") or "").lower() == normalized_kind]
+ if normalized_search:
+ works = [item for item in works if normalized_search in " ".join((
+ str(item.get("name") or ""), str(item.get("prompt") or ""),
+ str(item.get("model") or ""), str(item.get("operation") or ""),
+ )).lower()]
+ safe_limit = max(1, min(1000, int(limit or 500)))
+ return {"works": works[:safe_limit], "total": len(works)}
+
+
+def update_work_metadata(work_id: str, *, name: Optional[str] = None, favorite: Optional[bool] = None, trashed: Optional[bool] = None) -> Tuple[Dict[str, Any], int]:
+ work_id = str(work_id or "").strip()
+ target = next((item for item in all_generated_works() if item.get("id") == work_id), None)
+ if not target:
+ raise HTTPException(status_code=404, detail="作品不存在或对应文件记录已被移除")
+ now = time.time()
+ with WORK_METADATA_LOCK:
+ metadata = work_metadata()
+ entry = dict(metadata.get(work_id) or {})
+ if name is not None:
+ normalized_name = re.sub(r"[\r\n\t]+", " ", str(name)).strip()[:160]
+ if normalized_name and normalized_name != target.get("original_name"):
+ entry["name"] = normalized_name
+ else:
+ entry.pop("name", None)
+ if favorite is not None:
+ if favorite:
+ entry["favorite"] = True
+ entry["favorite_updated_at"] = now
+ else:
+ entry.pop("favorite", None)
+ entry.pop("favorite_updated_at", None)
+ if trashed is not None:
+ if trashed:
+ entry["trashed"] = True
+ entry["trashed_at"] = now
+ else:
+ entry.pop("trashed", None)
+ entry.pop("trashed_at", None)
+ entry["updated_at"] = now
+ meaningful_keys = {"name", "favorite", "favorite_updated_at", "trashed", "trashed_at"}
+ if any(key in entry for key in meaningful_keys):
+ metadata[work_id] = entry
+ else:
+ metadata.pop(work_id, None)
+ revision = DATABASE.put_document("works", "metadata", metadata)
+ publish_entity_changed("history", "works", revision)
+ refreshed = next((item for item in all_generated_works() if item.get("id") == work_id), target)
+ return refreshed, revision
+
+
+@app.put("/api/works/{work_id}/metadata")
+async def set_work_metadata(work_id: str, payload: WorkMetadataRequest):
+ if payload.name is None and payload.favorite is None and payload.trashed is None:
+ raise HTTPException(status_code=400, detail="没有需要修改的作品信息")
+ work, revision = update_work_metadata(
+ work_id,
+ name=payload.name,
+ favorite=payload.favorite,
+ trashed=payload.trashed,
+ )
+ return {"work": work, "revision": revision}
- if status == "SUCCEED":
- img_url = data["output_images"][0]
- local_path = ""
- try:
- async with httpx.AsyncClient() as dl_client:
- img_res = await dl_client.get(img_url)
- if img_res.status_code == 200:
- filename = f"ms_{req.model.replace('/', '_').replace(':', '_')}_{int(time.time())}.png"
- file_path = output_path_for(filename, "output")
- with open(file_path, "wb") as f:
- f.write(img_res.content)
- local_path = output_url_for(filename, "output")
- else:
- local_path = img_url
- except Exception:
- local_path = img_url
-
- record = {
- "timestamp": time.time(),
- "prompt": req.prompt,
- "images": [local_path],
- "type": "klein",
- "model": req.model,
- }
- save_to_history(record)
- if GLOBAL_LOOP:
- asyncio.run_coroutine_threadsafe(manager.broadcast_new_image(record), GLOBAL_LOOP)
- return {"url": local_path, "task_id": task_id}
-
- elif status in TERMINAL_FAILED_STATUSES:
- error_info = data.get("error_info") or data.get("message") or data.get("detail") or str(data)
- raise HTTPException(status_code=502, detail=f"MS task {status}: {error_info}")
-
- except HTTPException:
- raise
- except Exception as loop_e:
- print(f"MS polling error: {loop_e}")
- continue
- raise HTTPException(status_code=504, detail="MS 生图超时")
+@app.put("/api/works/{work_id}/favorite")
+async def set_work_favorite(work_id: str, payload: WorkFavoriteRequest):
+ work, revision = update_work_metadata(work_id, favorite=payload.favorite)
+ return {"work": work, "revision": revision}
- except HTTPException:
- raise
- except Exception as e:
- print(f"MS generate error: {e}")
- raise HTTPException(status_code=400, detail=str(e))
-
-# --- 本地 ComfyUI 生图 ---
-
-@app.post("/api/generate")
-def generate(req: GenerateRequest):
- global NEXT_TASK_ID
- current_task = None
- target_backend = None
- with QUEUE_LOCK:
- task_id = NEXT_TASK_ID
- NEXT_TASK_ID += 1
- current_task = {"task_id": task_id, "client_id": req.client_id}
- QUEUE.append(current_task)
+@app.post("/api/history/delete")
+async def delete_history(req: DeleteHistoryRequest):
try:
- required_images = collect_required_comfy_media(req.params)
-
- target_backend = reserve_best_backend(required_images)
-
- for image_name in required_images:
- need_sync = False
- try:
- check_url = f"http://{target_backend}/view?filename={urllib.parse.quote(image_name)}&type=input"
- resp = requests.get(check_url, stream=True, timeout=0.5)
- resp.close()
- if resp.status_code != 200:
- need_sync = True
- except:
- need_sync = True
-
- if need_sync:
- image_content = None
- image_type = "image/png"
- for addr in COMFYUI_INSTANCES:
- if addr == target_backend: continue
- try:
- src_url = f"http://{addr}/view?filename={urllib.parse.quote(image_name)}&type=input"
- r = requests.get(src_url, timeout=5)
- if r.status_code == 200:
- image_content = r.content
- image_type = r.headers.get("Content-Type", "image/png")
- break
- except: continue
+ with HISTORY_LOCK:
+ target_record = DATABASE.delete_history_timestamp(float(req.timestamp))
- if image_content:
+ if target_record:
+ for img_url in target_record.get("images", []):
+ file_path = output_file_from_url(img_url)
+ if file_path and os.path.exists(file_path):
try:
- files = {'image': (image_name, image_content, image_type)}
- requests.post(f"http://{target_backend}/upload/image", files=files, timeout=10)
+ os.remove(file_path)
except Exception as e:
- print(f"Sync upload failed: {e}")
-
- workflow_path = os.path.join(WORKFLOW_DIR, req.workflow_json)
- if not os.path.exists(workflow_path) and req.workflow_json == "Z-Image.json":
- workflow_path = WORKFLOW_PATH
- if not os.path.exists(workflow_path):
- raise Exception(f"Workflow file not found: {req.workflow_json}")
-
- with open(workflow_path, 'r', encoding='utf-8') as f:
- workflow = json.load(f)
-
- seed = random.randint(1, 4294967295)
-
- if "23" in workflow and req.prompt:
- workflow["23"]["inputs"]["text"] = req.prompt
- if "144" in workflow:
- workflow["144"]["inputs"]["width"] = req.width
- workflow["144"]["inputs"]["height"] = req.height
- if "22" in workflow:
- workflow["22"]["inputs"]["seed"] = seed
- if "158" in workflow:
- workflow["158"]["inputs"]["noise_seed"] = seed
- for node_id in ["146", "181"]:
- if node_id in workflow and "inputs" in workflow[node_id] and "seed" in workflow[node_id]["inputs"]:
- workflow[node_id]["inputs"]["seed"] = seed
- if "184" in workflow and "inputs" in workflow["184"] and "seed" in workflow["184"]["inputs"]:
- workflow["184"]["inputs"]["seed"] = seed
- if "172" in workflow and "inputs" in workflow["172"] and "seed" in workflow["172"]["inputs"]:
- workflow["172"]["inputs"]["seed"] = seed
- if "14" in workflow and "inputs" in workflow["14"] and "seed" in workflow["14"]["inputs"]:
- workflow["14"]["inputs"]["seed"] = seed
-
- for node_id, node_inputs in req.params.items():
- if node_id in workflow:
- if "inputs" not in workflow[node_id]:
- workflow[node_id]["inputs"] = {}
- for input_name, value in node_inputs.items():
- workflow[node_id]["inputs"][input_name] = value
-
- p = {"prompt": workflow, "client_id": CLIENT_ID}
- data = json.dumps(p).encode('utf-8')
- try:
- post_req = urllib.request.Request(f"http://{target_backend}/prompt", data=data)
- prompt_id = json.loads(urllib.request.urlopen(post_req, timeout=10).read())['prompt_id']
- except urllib.error.HTTPError as e:
- error_body = e.read().decode('utf-8')
- raise Exception(f"HTTP Error {e.code}: {error_body}")
-
- history_data = None
- for i in range(COMFYUI_HISTORY_TIMEOUT):
- try:
- res = get_comfy_history(target_backend, prompt_id)
- if prompt_id in res:
- history_data = res[prompt_id]
- break
- except Exception:
- pass
- time.sleep(1)
-
- if not history_data:
- raise Exception("ComfyUI 渲染超时")
-
- local_images = []
- local_videos = []
- local_audios = []
- local_texts = []
- local_files = []
- local_items = []
- local_urls = []
- current_timestamp = time.time()
- if 'outputs' in history_data:
- # 先把所有节点的输出收集为候选(带上 class_type),再决定下载哪些,
- # 避免把冗余的预览/对比图、调试文本一起下载进结果(后端层过滤,历史记录也更干净)。
- workflow_nodes = workflow if isinstance(workflow, dict) else {}
- def _class_type_of(nid):
- node_def = workflow_nodes.get(str(nid))
- return str(node_def.get("class_type") or "") if isinstance(node_def, dict) else ""
- file_candidates = [] # (node_id, class_type, output_key, item, kind)
- text_candidates = [] # (node_id, class_type, text, name)
- for node_id in history_data['outputs']:
- node_output = history_data['outputs'][node_id]
- class_type = _class_type_of(node_id)
- for output_key, item in collect_comfy_file_items(node_output):
- file_candidates.append((node_id, class_type, output_key, item, comfy_output_kind(item)))
- for text, name in comfy_text_values_from_output(node_output):
- text_candidates.append((node_id, class_type, text, name))
-
- # 只要存在“非预览节点”产出的图片,就把 PreviewImage/对比节点的图片视为冗余丢弃;
- # 若整个工作流只有预览图(没有 SaveImage 等),则保留预览图作为唯一结果,避免零输出。
- has_primary_image = any(
- kind == "image" and not comfy_class_is_preview(ct)
- for (_nid, ct, _ok, _it, kind) in file_candidates
- )
- prefix = f"{req.type}_{int(current_timestamp)}_"
- for node_id, class_type, output_key, item, kind in file_candidates:
- if kind == "image" and has_primary_image and comfy_class_is_preview(class_type):
- continue # 跳过冗余的预览/对比图
- local_path = download_comfy_output(target_backend, item, prefix=prefix)
- if kind == "image" and req.convert_to_jpg:
- local_path = convert_output_to_jpg(local_path)
- name = os.path.basename(str(item.get("filename") or "")) or os.path.basename(str(local_path).split("?", 1)[0])
- entry = {
- "url": local_path,
- "kind": kind,
- "name": name,
- "node_id": str(node_id),
- "output_key": str(output_key),
- "class_type": class_type,
- }
- if kind == "image":
- local_images.append(local_path)
- elif kind == "video":
- local_videos.append(local_path)
- elif kind == "audio":
- local_audios.append(local_path)
- elif kind == "text":
- local_texts.append(local_path)
- else:
- local_files.append(local_path)
- local_items.append(entry)
- local_urls.append(local_path)
-
- # 默认抑制 show/utility 类节点的调试文本,避免 .txt 噪声混入结果。
- for node_id, class_type, text, name in text_candidates:
- if comfy_class_is_debug_text(class_type):
- continue
- local_path = save_comfy_text_output(text, prefix=prefix, name=name)
- entry = {
- "url": local_path,
- "kind": "text",
- "name": os.path.basename(str(local_path).split("?", 1)[0]),
- "node_id": str(node_id),
- "output_key": "text",
- "class_type": class_type,
- }
- local_texts.append(local_path)
- local_items.append(entry)
- local_urls.append(local_path)
-
- result = {
- "prompt": req.prompt if req.prompt else "Detail Enhance",
- "images": local_images,
- "videos": local_videos,
- "audios": local_audios,
- "texts": local_texts,
- "files": local_files,
- "items": local_items,
- "outputs": local_urls,
- "seed": seed,
- "timestamp": current_timestamp,
- "type": req.type,
- "workflow_json": req.workflow_json,
- "task_id": task_id,
- "prompt_id": prompt_id,
- "backend": target_backend,
- "params": req.params
- }
- save_to_history(result)
- if GLOBAL_LOOP:
- asyncio.run_coroutine_threadsafe(manager.broadcast_new_image(result), GLOBAL_LOOP)
- return result
-
+ print(f"Failed to delete file {file_path}: {e}")
+ publish_entity_changed("history", "global")
+ return {"success": True}
+ else:
+ return {"success": False, "message": "Record not found"}
except Exception as e:
- return {"images": [], "error": str(e)}
- finally:
- if target_backend:
- with LOAD_LOCK:
- if BACKEND_LOCAL_LOAD.get(target_backend, 0) > 0:
- BACKEND_LOCAL_LOAD[target_backend] -= 1
- if current_task:
- with QUEUE_LOCK:
- if current_task in QUEUE:
- QUEUE.remove(current_task)
-
-# --- ComfyUI 工作流管理 ---
-
-BUILTIN_WORKFLOWS = {"Z-Image.json", "Z-Image-Enhance.json", "2511.json", "klein-enhance.json", "Flux2-Klein.json", "upscale.json"}
-CUSTOM_WORKFLOW_FOLDER = "custom"
-LEGACY_CUSTOM_WORKFLOW_FOLDER = "自定义"
-WORKFLOW_NAME_RE = re.compile(rf"^(?:(?:{CUSTOM_WORKFLOW_FOLDER}|{LEGACY_CUSTOM_WORKFLOW_FOLDER})/)?[a-zA-Z0-9_一-龥\.\-]+\.json$")
-
-class WorkflowField(BaseModel):
- id: str
- node: str = ""
- input: str = ""
- name: str = ""
- type: str = "text"
- default: Any = None
- min: Optional[float] = None
- max: Optional[float] = None
- step: Optional[float] = None
- options: List[str] = []
- random_enabled: bool = False
-
-class WorkflowConfig(BaseModel):
- title: str = ""
- fields: List[WorkflowField] = []
- mini_cards: Dict[str, Any] = {}
-
-class WorkflowUploadRequest(BaseModel):
- name: str
- workflow: Dict[str, Any]
-
-class WorkflowRunRequest(BaseModel):
- fields: Dict[str, Any] = {}
- config: WorkflowConfig
- client_id: str = ""
-
-def workflow_path_from_name(name: str) -> str:
- if not WORKFLOW_NAME_RE.match(name):
- raise HTTPException(status_code=400, detail="Invalid workflow name")
- path = os.path.abspath(os.path.join(WORKFLOW_DIR, *name.split("/")))
- workflow_root = os.path.abspath(WORKFLOW_DIR)
- if os.path.commonpath([workflow_root, path]) != workflow_root:
- raise HTTPException(status_code=400, detail="Invalid workflow name")
- return path
-
-def workflow_config_path(name: str) -> str:
- return workflow_path_from_name(name).replace(".json", ".config.json")
-
-def is_builtin_workflow(name: str) -> bool:
- return "/" not in name and os.path.basename(name) in BUILTIN_WORKFLOWS
+ print(f"Delete history error: {e}")
+ return {"success": False, "message": str(e)}
def runninghub_workflow_store_path() -> str:
- return RUNNINGHUB_WORKFLOW_STORE_FILE
+ return str(DATA_LAYOUT.database_file)
def load_runninghub_workflow_store():
- if not os.path.exists(RUNNINGHUB_WORKFLOW_STORE_FILE):
- return {}
- try:
- with open(RUNNINGHUB_WORKFLOW_STORE_FILE, "r", encoding="utf-8") as f:
- data = json.load(f)
- return data if isinstance(data, dict) else {}
- except Exception:
- return {}
+ data = DATABASE.get_library("runninghub_workflows", {})
+ return data if isinstance(data, dict) else {}
def save_runninghub_workflow_store(store):
- os.makedirs(DATA_DIR, exist_ok=True)
- with open(RUNNINGHUB_WORKFLOW_STORE_FILE, "w", encoding="utf-8") as f:
- json.dump(store, f, ensure_ascii=False, indent=2)
+ revision = DATABASE.save_library("runninghub_workflows", store)
+ publish_entity_changed("workflow", "runninghub", revision)
def runninghub_workflow_config_has_payload(cfg):
if not isinstance(cfg, dict):
@@ -14881,193 +15915,8 @@ def runninghub_collect_workflow_fields(workflow_json):
})
return fields
-class ComfyInstancesPayload(BaseModel):
- instances: List[str] = []
-
-@app.get("/api/comfyui/instances")
-def get_comfyui_instances():
- return {"instances": COMFYUI_INSTANCES}
-
-@app.put("/api/comfyui/instances")
-def save_comfyui_instances(payload: ComfyInstancesPayload):
- # 宽容校验:去前后空白、去 http(s):// 前缀、去尾部斜杠;要求形如 host:port
- cleaned = []
- for item in payload.instances:
- s = str(item or "").strip()
- if not s:
- continue
- s = re.sub(r"^https?://", "", s)
- s = s.rstrip("/")
- if ":" not in s:
- raise HTTPException(status_code=400, detail=f"地址缺少端口号:{item}(应为 host:port,例如 127.0.0.1:8188)")
- host, _, port = s.rpartition(":")
- if not host or not port.isdigit():
- raise HTTPException(status_code=400, detail=f"地址不合法:{item}(应为 host:port,例如 127.0.0.1:8188)")
- if s in cleaned:
- continue
- cleaned.append(s)
- if not cleaned:
- raise HTTPException(status_code=400, detail="至少保留一个 ComfyUI 后端地址")
- # 写入 env 文件
- try:
- update_env_values({"COMFYUI_INSTANCES": ",".join(cleaned)})
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"写入 env 失败:{e}")
- # 更新进程中的全局变量
- global COMFYUI_INSTANCES, COMFYUI_ADDRESS, BACKEND_LOCAL_LOAD
- COMFYUI_INSTANCES = cleaned
- COMFYUI_ADDRESS = cleaned[0]
- new_load = {addr: 0 for addr in cleaned}
- for addr, n in (BACKEND_LOCAL_LOAD or {}).items():
- if addr in new_load:
- new_load[addr] = n
- BACKEND_LOCAL_LOAD = new_load
- return {"instances": COMFYUI_INSTANCES}
-
-@app.get("/api/workflows")
-def list_workflows():
- if not os.path.isdir(WORKFLOW_DIR):
- return {"workflows": []}
- items = []
- for root, dirs, files in os.walk(WORKFLOW_DIR):
- if os.path.abspath(root) == os.path.abspath(WORKFLOW_DIR):
- dirs[:] = [d for d in dirs if d in {CUSTOM_WORKFLOW_FOLDER, LEGACY_CUSTOM_WORKFLOW_FOLDER}]
- for fn in sorted(files):
- if not fn.endswith(".json") or fn.endswith(".config.json"):
- continue
- rel = os.path.relpath(os.path.join(root, fn), WORKFLOW_DIR).replace("\\", "/")
- if is_builtin_workflow(rel):
- continue
- cfg = {}
- cfg_path = workflow_config_path(rel)
- if os.path.exists(cfg_path):
- try:
- with open(cfg_path, "r", encoding="utf-8") as f:
- cfg = json.load(f) or {}
- except Exception:
- cfg = {}
- items.append({
- "name": rel,
- "title": cfg.get("title") or fn.replace(".json", ""),
- "builtin": False,
- "field_count": len(cfg.get("fields") or []),
- })
- items.sort(key=lambda item: (0 if item["name"].startswith(f"{CUSTOM_WORKFLOW_FOLDER}/") else 1, item["title"]))
- return {"workflows": items}
-
-@app.get("/api/workflows/{name:path}")
-def get_workflow(name: str):
- if not WORKFLOW_NAME_RE.match(name):
- raise HTTPException(status_code=400, detail="Invalid workflow name")
- workflow_path = workflow_path_from_name(name)
- if not os.path.exists(workflow_path):
- raise HTTPException(status_code=404, detail="Workflow not found")
- with open(workflow_path, "r", encoding="utf-8") as f:
- workflow = json.load(f)
- cfg = {"title": name.replace(".json", ""), "fields": []}
- cfg_path = workflow_config_path(name)
- if os.path.exists(cfg_path):
- try:
- with open(cfg_path, "r", encoding="utf-8") as f:
- cfg = json.load(f) or cfg
- except Exception:
- pass
- return {"name": name, "workflow": workflow, "config": cfg, "builtin": is_builtin_workflow(name)}
-
-@app.post("/api/workflows")
-def upload_workflow(payload: WorkflowUploadRequest):
- name = os.path.basename(payload.name.strip())
- if not name.endswith(".json"):
- name = name + ".json"
- if not WORKFLOW_NAME_RE.match(name):
- raise HTTPException(status_code=400, detail="工作流名称不合法,请使用中文/英文/数字/_-.")
- if not isinstance(payload.workflow, dict) or not payload.workflow:
- raise HTTPException(status_code=400, detail="工作流 JSON 为空")
- # 简单校验:是 API 格式(节点 id 为 key,含 class_type)
- sample = next(iter(payload.workflow.values()), None)
- if not isinstance(sample, dict) or "class_type" not in sample:
- raise HTTPException(status_code=400, detail="不是有效的 ComfyUI API 工作流 JSON(需包含 class_type)")
- custom_dir = os.path.join(WORKFLOW_DIR, CUSTOM_WORKFLOW_FOLDER)
- os.makedirs(custom_dir, exist_ok=True)
- stored_name = f"{CUSTOM_WORKFLOW_FOLDER}/{name}"
- path = workflow_path_from_name(stored_name)
- with open(path, "w", encoding="utf-8") as f:
- json.dump(payload.workflow, f, ensure_ascii=False, indent=2)
- return {"name": stored_name}
-
-@app.put("/api/workflows/{name:path}/config")
-def save_workflow_config(name: str, payload: WorkflowConfig):
- if not WORKFLOW_NAME_RE.match(name):
- raise HTTPException(status_code=400, detail="Invalid workflow name")
- workflow_path = workflow_path_from_name(name)
- if not os.path.exists(workflow_path):
- raise HTTPException(status_code=404, detail="Workflow not found")
- cfg_path = workflow_config_path(name)
- with open(cfg_path, "w", encoding="utf-8") as f:
- json.dump(payload.dict(), f, ensure_ascii=False, indent=2)
- return {"config": payload.dict()}
-
-@app.delete("/api/workflows/{name:path}")
-def delete_workflow(name: str):
- if not WORKFLOW_NAME_RE.match(name):
- raise HTTPException(status_code=400, detail="Invalid workflow name")
- if is_builtin_workflow(name):
- raise HTTPException(status_code=400, detail="内置工作流不可删除")
- workflow_path = workflow_path_from_name(name)
- cfg_path = workflow_config_path(name)
- if not os.path.exists(workflow_path):
- raise HTTPException(status_code=404, detail="Workflow not found")
- os.remove(workflow_path)
- if os.path.exists(cfg_path):
- os.remove(cfg_path)
- return {"ok": True}
-
-@app.post("/api/workflows/{name:path}/run")
-def run_workflow(name: str, payload: WorkflowRunRequest):
- if not WORKFLOW_NAME_RE.match(name):
- raise HTTPException(status_code=400, detail="Invalid workflow name")
- if not os.path.exists(workflow_path_from_name(name)):
- raise HTTPException(status_code=404, detail="Workflow not found")
- # 根据 config 的字段把值映射成 params 节点覆盖
- params: Dict[str, Dict[str, Any]] = {}
- for field in payload.config.fields:
- if not field.node or not field.input:
- continue
- if field.id in payload.fields:
- value = payload.fields[field.id]
- # 类型转换
- if field.type in ("number", "slider"):
- try:
- value = float(value) if (field.step and field.step < 1) else int(float(value))
- except Exception:
- pass
- elif field.type == "boolean":
- value = bool(value)
- elif field.type == "dropdown":
- # 下拉值如果看起来是数字(如 "1024" / "2048" / "0.8"),自动转成 int/float
- if isinstance(value, str):
- s = value.strip()
- try:
- if s and ('.' in s or 'e' in s.lower()):
- value = float(s)
- elif s and (s.lstrip('-').isdigit()):
- value = int(s)
- except (ValueError, TypeError):
- pass
- params.setdefault(field.node, {})[field.input] = value
- req = GenerateRequest(
- prompt="",
- workflow_json=name,
- params=params,
- type="workflow-test",
- client_id=payload.client_id or str(uuid.uuid4()),
- )
- return generate(req)
-
if __name__ == "__main__":
- import uvicorn
# 关闭服务端协议级 WebSocket ping:部分客户端(如 PS UXP 面板)不会自动回 pong,
# 默认 20s ping/20s 超时会把这些连接每隔一会儿就踢掉造成"频繁断连"。
# 客户端有自己的应用层心跳 + 断线重连兜底,这里禁用协议 ping 更稳。
- uvicorn.run(app, host="0.0.0.0", port=3000,
- ws_ping_interval=None, ws_ping_timeout=None)
+ run_uvicorn(app)
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 000000000..a76eb9451
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,247 @@
+{
+ "name": "canvas-windows-desktop",
+ "version": "1.0.16",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "canvas-windows-desktop",
+ "version": "1.0.16",
+ "devDependencies": {
+ "@tauri-apps/cli": "2.11.4"
+ }
+ },
+ "node_modules/@tauri-apps/cli": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
+ "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "bin": {
+ "tauri": "tauri.js"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/tauri"
+ },
+ "optionalDependencies": {
+ "@tauri-apps/cli-darwin-arm64": "2.11.4",
+ "@tauri-apps/cli-darwin-x64": "2.11.4",
+ "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
+ "@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
+ "@tauri-apps/cli-linux-arm64-musl": "2.11.4",
+ "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
+ "@tauri-apps/cli-linux-x64-gnu": "2.11.4",
+ "@tauri-apps/cli-linux-x64-musl": "2.11.4",
+ "@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
+ "@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
+ "@tauri-apps/cli-win32-x64-msvc": "2.11.4"
+ }
+ },
+ "node_modules/@tauri-apps/cli-darwin-arm64": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
+ "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-darwin-x64": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
+ "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
+ "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
+ "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm64-musl": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
+ "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
+ "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-x64-gnu": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
+ "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-x64-musl": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
+ "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
+ "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
+ "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-x64-msvc": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
+ "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 000000000..7bc0c7473
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "canvas-windows-desktop",
+ "private": true,
+ "version": "1.0.16",
+ "scripts": {
+ "desktop:dev": "tauri dev",
+ "desktop:build": "tauri build --no-bundle",
+ "portable:build": "powershell -ExecutionPolicy Bypass -File tools/build-portable.ps1",
+ "portable:release": "powershell -ExecutionPolicy Bypass -File tools/build-portable.ps1 -IncrementVersion",
+ "version:patch": "powershell -ExecutionPolicy Bypass -File tools/increment-version.ps1"
+ },
+ "devDependencies": {
+ "@tauri-apps/cli": "2.11.4"
+ }
+}
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 000000000..565391d5e
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+testpaths = tests
+norecursedirs = .build backup data dist node_modules python
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
new file mode 100644
index 000000000..2c265bcba
--- /dev/null
+++ b/src-tauri/Cargo.lock
@@ -0,0 +1,5515 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "arboard"
+version = "3.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+dependencies = [
+ "clipboard-win",
+ "image",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "parking_lot",
+ "percent-encoding",
+ "windows-sys 0.60.2",
+ "x11rb",
+]
+
+[[package]]
+name = "ashpd"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
+dependencies = [
+ "async-fs",
+ "async-net",
+ "enumflags2",
+ "futures-channel",
+ "futures-util",
+ "rand",
+ "raw-window-handle",
+ "serde",
+ "serde_repr",
+ "url",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "zbus",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-fs"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
+dependencies = [
+ "async-lock",
+ "blocking",
+ "futures-lite",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-net"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
+dependencies = [
+ "async-io",
+ "blocking",
+ "futures-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.2",
+]
+
+[[package]]
+name = "atk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
+dependencies = [
+ "atk-sys",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "atk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "brotli"
+version = "8.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cairo-rs"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
+dependencies = [
+ "bitflags 2.13.1",
+ "cairo-sys-rs",
+ "glib",
+ "libc",
+ "once_cell",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cairo-sys-rs"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "camino"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "canvas-desktop"
+version = "1.0.16"
+dependencies = [
+ "arboard",
+ "open",
+ "rfd",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-build",
+ "tauri-plugin-single-instance",
+ "ureq",
+ "uuid",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.19",
+]
+
+[[package]]
+name = "cargo_toml"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
+dependencies = [
+ "serde",
+ "toml 0.9.12+spec-1.1.0",
+]
+
+[[package]]
+name = "cc"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfb"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+dependencies = [
+ "byteorder",
+ "fnv",
+ "uuid",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "clipboard-win"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+dependencies = [
+ "error-code",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "cookie_store"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
+dependencies = [
+ "cookie",
+ "document-features",
+ "idna",
+ "indexmap 2.14.0",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "time",
+ "url",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
+dependencies = [
+ "bitflags 2.13.1",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.13.1",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "ctor"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
+dependencies = [
+ "ctor-proc-macro",
+ "dtor",
+]
+
+[[package]]
+name = "ctor-proc-macro"
+version = "0.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "dbus"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
+dependencies = [
+ "libc",
+ "libdbus-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading 0.8.9",
+]
+
+[[package]]
+name = "dlopen2"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
+dependencies = [
+ "dlopen2_derive",
+ "libc",
+ "once_cell",
+ "winapi",
+]
+
+[[package]]
+name = "dlopen2_derive"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "dom_query"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
+dependencies = [
+ "bit-set",
+ "cssparser",
+ "foldhash",
+ "html5ever",
+ "precomputed-hash",
+ "selectors",
+ "tendril",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "dtor"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
+dependencies = [
+ "dtor-proc-macro",
+]
+
+[[package]]
+name = "dtor-proc-macro"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "embed-resource"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd"
+dependencies = [
+ "cc",
+ "memchr",
+ "rustc_version",
+ "toml 1.1.3+spec-1.1.0",
+ "vswhom",
+ "winreg",
+]
+
+[[package]]
+name = "embed_plist"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "error-code"
+version = "3.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fax"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "field-offset"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
+dependencies = [
+ "memoffset",
+ "rustc_version",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gdk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
+dependencies = [
+ "cairo-rs",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gio",
+ "glib",
+ "libc",
+ "pango",
+]
+
+[[package]]
+name = "gdk-pixbuf"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
+dependencies = [
+ "gdk-pixbuf-sys",
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+]
+
+[[package]]
+name = "gdk-pixbuf-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gdk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
+dependencies = [
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkwayland-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkx11"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe"
+dependencies = [
+ "gdk",
+ "gdkx11-sys",
+ "gio",
+ "glib",
+ "libc",
+ "x11",
+]
+
+[[package]]
+name = "gdkx11-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "libc",
+ "system-deps",
+ "x11",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "gio"
+version = "0.18.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "gio-sys",
+ "glib",
+ "libc",
+ "once_cell",
+ "pin-project-lite",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "gio-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+ "winapi",
+]
+
+[[package]]
+name = "glib"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
+dependencies = [
+ "bitflags 2.13.1",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "futures-util",
+ "gio-sys",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "memchr",
+ "once_cell",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro-crate 2.0.2",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
+dependencies = [
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "gobject-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
+dependencies = [
+ "atk",
+ "cairo-rs",
+ "field-offset",
+ "futures-channel",
+ "gdk",
+ "gdk-pixbuf",
+ "gio",
+ "glib",
+ "gtk-sys",
+ "gtk3-macros",
+ "libc",
+ "pango",
+ "pkg-config",
+]
+
+[[package]]
+name = "gtk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
+dependencies = [
+ "atk-sys",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk3-macros"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
+dependencies = [
+ "proc-macro-crate 1.3.1",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "zerocopy",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "html5ever"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ico"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
+dependencies = [
+ "byteorder",
+ "png 0.17.16",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png 0.18.1",
+ "tiff",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "infer"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
+dependencies = [
+ "cfb",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "javascriptcore-rs"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "javascriptcore-rs-sys",
+]
+
+[[package]]
+name = "javascriptcore-rs-sys"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "json-patch"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08"
+dependencies = [
+ "jsonptr",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "jsonptr"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "keyboard-types"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+dependencies = [
+ "bitflags 2.13.1",
+ "serde",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "libappindicator"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
+dependencies = [
+ "glib",
+ "gtk",
+ "gtk-sys",
+ "libappindicator-sys",
+ "log",
+]
+
+[[package]]
+name = "libappindicator-sys"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
+dependencies = [
+ "gtk-sys",
+ "libloading 0.7.4",
+ "once_cell",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.188"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b"
+
+[[package]]
+name = "libdbus-sys"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "libloading"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
+dependencies = [
+ "cfg-if",
+ "winapi",
+]
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "markup5ever"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "muda"
+version = "0.19.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "gtk",
+ "keyboard-types",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "once_cell",
+ "png 0.18.1",
+ "serde",
+ "thiserror 2.0.19",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.13.1",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+ "objc2-exception-helper",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.1",
+ "dispatch2",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.13.1",
+ "dispatch2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-exception-helper"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2",
+ "objc2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-core-text",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-web-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "open"
+version = "5.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
+dependencies = [
+ "is-wsl",
+ "libc",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "pango"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
+dependencies = [
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+ "pango-sys",
+]
+
+[[package]]
+name = "pango-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plist"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap 2.14.0",
+ "quick-xml 0.41.0",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.1",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "proc-macro-crate"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
+dependencies = [
+ "once_cell",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
+dependencies = [
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.13+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
+
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 2.0.19",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.2",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "sync_wrapper",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
+[[package]]
+name = "rfd"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
+dependencies = [
+ "ashpd",
+ "block2",
+ "dispatch2",
+ "js-sys",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "pollster",
+ "raw-window-handle",
+ "urlencoding",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schemars"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
+dependencies = [
+ "dyn-clone",
+ "indexmap 1.9.3",
+ "schemars_derive",
+ "serde",
+ "serde_json",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars_derive"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "selectors"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
+dependencies = [
+ "bitflags 2.13.1",
+ "cssparser",
+ "derive_more",
+ "log",
+ "new_debug_unreachable",
+ "phf",
+ "phf_codegen",
+ "precomputed-hash",
+ "rustc-hash",
+ "servo_arc",
+ "smallvec",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-untagged"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
+dependencies = [
+ "erased-serde",
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.2",
+]
+
+[[package]]
+name = "serde_derive_internals"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.2",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
+dependencies = [
+ "base64 0.22.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "serialize-to-javascript"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serialize-to-javascript-impl",
+]
+
+[[package]]
+name = "serialize-to-javascript-impl"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "servo_arc"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "softbuffer"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
+dependencies = [
+ "bytemuck",
+ "js-sys",
+ "ndk",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "raw-window-handle",
+ "redox_syscall",
+ "tracing",
+ "wasm-bindgen",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "soup3"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
+dependencies = [
+ "futures-channel",
+ "gio",
+ "glib",
+ "libc",
+ "soup3-sys",
+]
+
+[[package]]
+name = "soup3-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "swift-rs"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7"
+dependencies = [
+ "base64 0.21.7",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml 0.8.2",
+ "version-compare",
+]
+
+[[package]]
+name = "tao"
+version = "0.35.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2",
+ "core-foundation",
+ "core-graphics",
+ "crossbeam-channel",
+ "dbus",
+ "dispatch2",
+ "dlopen2",
+ "dpi",
+ "gdkwayland-sys",
+ "gdkx11-sys",
+ "gtk",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-sys",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "once_cell",
+ "parking_lot",
+ "percent-encoding",
+ "raw-window-handle",
+ "tao-macros",
+ "unicode-segmentation",
+ "url",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "tao-macros"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "tauri"
+version = "2.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "cookie",
+ "dirs",
+ "dunce",
+ "embed_plist",
+ "getrandom 0.3.4",
+ "glob",
+ "gtk",
+ "heck 0.5.0",
+ "http",
+ "jni",
+ "libc",
+ "log",
+ "mime",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "percent-encoding",
+ "plist",
+ "raw-window-handle",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "serialize-to-javascript",
+ "swift-rs",
+ "tauri-build",
+ "tauri-macros",
+ "tauri-runtime",
+ "tauri-runtime-wry",
+ "tauri-utils",
+ "thiserror 2.0.19",
+ "tokio",
+ "tray-icon",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "window-vibrancy",
+ "windows",
+]
+
+[[package]]
+name = "tauri-build"
+version = "2.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
+dependencies = [
+ "anyhow",
+ "cargo_toml",
+ "dirs",
+ "glob",
+ "heck 0.5.0",
+ "json-patch",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "tauri-winres",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-codegen"
+version = "2.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5"
+dependencies = [
+ "base64 0.22.1",
+ "brotli",
+ "ico",
+ "json-patch",
+ "plist",
+ "png 0.17.16",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "syn 2.0.119",
+ "tauri-utils",
+ "thiserror 2.0.19",
+ "time",
+ "url",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-macros"
+version = "2.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "tauri-codegen",
+ "tauri-utils",
+]
+
+[[package]]
+name = "tauri-plugin-single-instance"
+version = "2.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "thiserror 2.0.19",
+ "tokio",
+ "tracing",
+ "windows-sys 0.60.2",
+ "zbus",
+]
+
+[[package]]
+name = "tauri-runtime"
+version = "2.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8"
+dependencies = [
+ "cookie",
+ "dpi",
+ "gtk",
+ "http",
+ "jni",
+ "objc2",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "raw-window-handle",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "thiserror 2.0.19",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+]
+
+[[package]]
+name = "tauri-runtime-wry"
+version = "2.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
+dependencies = [
+ "gtk",
+ "http",
+ "jni",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "softbuffer",
+ "tao",
+ "tauri-runtime",
+ "tauri-utils",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+ "wry",
+]
+
+[[package]]
+name = "tauri-utils"
+version = "2.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887"
+dependencies = [
+ "anyhow",
+ "brotli",
+ "cargo_metadata",
+ "ctor",
+ "dom_query",
+ "dunce",
+ "glob",
+ "http",
+ "infer",
+ "json-patch",
+ "log",
+ "memchr",
+ "phf",
+ "plist",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde-untagged",
+ "serde_json",
+ "serde_with",
+ "swift-rs",
+ "thiserror 2.0.19",
+ "toml 1.1.3+spec-1.1.0",
+ "url",
+ "urlpattern",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-winres"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
+dependencies = [
+ "dunce",
+ "embed-resource",
+ "toml 1.1.3+spec-1.1.0",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tendril"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
+dependencies = [
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl 2.0.19",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.2",
+]
+
+[[package]]
+name = "tiff"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
+dependencies = [
+ "fax",
+ "flate2",
+ "half",
+ "quick-error",
+ "weezl",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "time"
+version = "0.3.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.13+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.1",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tray-icon"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
+dependencies = [
+ "crossbeam-channel",
+ "dirs",
+ "libappindicator",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "once_cell",
+ "png 0.18.1",
+ "serde",
+ "thiserror 2.0.19",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unic-char-property"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
+dependencies = [
+ "unic-char-range",
+]
+
+[[package]]
+name = "unic-char-range"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
+
+[[package]]
+name = "unic-common"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
+
+[[package]]
+name = "unic-ucd-ident"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
+dependencies = [
+ "unic-char-property",
+ "unic-char-range",
+ "unic-ucd-version",
+]
+
+[[package]]
+name = "unic-ucd-version"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
+dependencies = [
+ "unic-common",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
+dependencies = [
+ "base64 0.22.1",
+ "cookie_store",
+ "flate2",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "ureq-proto",
+ "utf8-zero",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
+dependencies = [
+ "base64 0.22.1",
+ "http",
+ "httparse",
+ "log",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
+[[package]]
+name = "urlpattern"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
+dependencies = [
+ "regex",
+ "serde",
+ "unic-ucd-ident",
+ "url",
+]
+
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vswhom"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
+dependencies = [
+ "libc",
+ "vswhom-sys",
+]
+
+[[package]]
+name = "vswhom-sys"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
+dependencies = [
+ "bitflags 2.13.1",
+ "rustix",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
+dependencies = [
+ "bitflags 2.13.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
+dependencies = [
+ "proc-macro2",
+ "quick-xml 0.39.4",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "pkg-config",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web_atoms"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "webkit2gtk"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk",
+ "gdk-sys",
+ "gio",
+ "gio-sys",
+ "glib",
+ "glib-sys",
+ "gobject-sys",
+ "gtk",
+ "gtk-sys",
+ "javascriptcore-rs",
+ "libc",
+ "once_cell",
+ "soup3",
+ "webkit2gtk-sys",
+]
+
+[[package]]
+name = "webkit2gtk-sys"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "javascriptcore-rs-sys",
+ "libc",
+ "pkg-config",
+ "soup3-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "webview2-com"
+version = "0.38.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-implement",
+ "windows-interface",
+]
+
+[[package]]
+name = "webview2-com-macros"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "webview2-com-sys"
+version = "0.38.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
+dependencies = [
+ "thiserror 2.0.19",
+ "windows",
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "window-vibrancy"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
+dependencies = [
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "windows-sys 0.59.0",
+ "windows-version",
+]
+
+[[package]]
+name = "windows"
+version = "0.61.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+dependencies = [
+ "windows-collections",
+ "windows-core 0.61.2",
+ "windows-future",
+ "windows-link 0.1.3",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+dependencies = [
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-version"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winreg"
+version = "0.55.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "wry"
+version = "0.55.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
+dependencies = [
+ "base64 0.22.1",
+ "block2",
+ "cookie",
+ "crossbeam-channel",
+ "dirs",
+ "dom_query",
+ "dpi",
+ "dunce",
+ "gdkx11",
+ "gtk",
+ "http",
+ "javascriptcore-rs",
+ "jni",
+ "libc",
+ "ndk",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "sha2",
+ "soup3",
+ "tao-macros",
+ "thiserror 2.0.19",
+ "url",
+ "webkit2gtk",
+ "webkit2gtk-sys",
+ "webview2-com",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 1.0.4",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow 1.0.4",
+ "zvariant",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zune-core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
+dependencies = [
+ "zune-core",
+]
+
+[[package]]
+name = "zvariant"
+version = "5.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "url",
+ "winnow 1.0.4",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.119",
+ "winnow 1.0.4",
+]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
new file mode 100644
index 000000000..0aa786b92
--- /dev/null
+++ b/src-tauri/Cargo.toml
@@ -0,0 +1,36 @@
+[package]
+name = "canvas-desktop"
+version = "1.0.16"
+description = "Canvas Windows portable desktop host"
+authors = ["Canvas contributors"]
+edition = "2021"
+license-file = "../LICENSE"
+
+[lib]
+name = "canvas_desktop_lib"
+crate-type = ["staticlib", "cdylib", "rlib"]
+
+[[bin]]
+name = "Canvas"
+path = "src/main.rs"
+
+[build-dependencies]
+tauri-build = { version = "2.5.3", features = [] }
+
+[dependencies]
+arboard = "3.6.1"
+open = "5.3.2"
+rfd = "0.15.4"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+tauri = { version = "2.11.5", features = ["tray-icon"] }
+tauri-plugin-single-instance = "2.3.6"
+ureq = { version = "3.1.4", features = ["json"] }
+uuid = { version = "1.18.1", features = ["v4"] }
+
+[profile.release]
+codegen-units = 1
+lto = "thin"
+opt-level = "s"
+panic = "abort"
+strip = true
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
new file mode 100644
index 000000000..d860e1e6a
--- /dev/null
+++ b/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ tauri_build::build()
+}
diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png
new file mode 100644
index 000000000..279020cb6
Binary files /dev/null and b/src-tauri/icons/128x128.png differ
diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png
new file mode 100644
index 000000000..28e564c59
Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ
diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png
new file mode 100644
index 000000000..94dbae26e
Binary files /dev/null and b/src-tauri/icons/32x32.png differ
diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png
new file mode 100644
index 000000000..9193e431d
Binary files /dev/null and b/src-tauri/icons/64x64.png differ
diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png
new file mode 100644
index 000000000..9f14d1a65
Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ
diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png
new file mode 100644
index 000000000..2b2ce70bf
Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ
diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png
new file mode 100644
index 000000000..6c2abd8ab
Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ
diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png
new file mode 100644
index 000000000..90b367682
Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ
diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png
new file mode 100644
index 000000000..308a2e645
Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ
diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png
new file mode 100644
index 000000000..4222e7adf
Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ
diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png
new file mode 100644
index 000000000..4e7b93c36
Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ
diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png
new file mode 100644
index 000000000..caf261655
Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ
diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png
new file mode 100644
index 000000000..9d87b2d64
Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ
diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png
new file mode 100644
index 000000000..feddfa11b
Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ
diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..2ffbf24b6
--- /dev/null
+++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..55ca6aed8
Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..fc96b76d8
Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..56ae257ac
Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..ba704514d
Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..173e90102
Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..e3d559916
Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..0139e5872
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..8a0df6b2d
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..fb0b7cc26
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..09f3587cc
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..dc07e7142
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..7ce1ac61f
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..5f2d0c94a
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..1eba8142d
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..1278dd87b
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml
new file mode 100644
index 000000000..ea9c223a6
--- /dev/null
+++ b/src-tauri/icons/android/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #fff
+
\ No newline at end of file
diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns
new file mode 100644
index 000000000..bd41ff903
Binary files /dev/null and b/src-tauri/icons/icon.icns differ
diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico
new file mode 100644
index 000000000..81c77c565
Binary files /dev/null and b/src-tauri/icons/icon.ico differ
diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png
new file mode 100644
index 000000000..062b02363
Binary files /dev/null and b/src-tauri/icons/icon.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png
new file mode 100644
index 000000000..060657eb1
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
new file mode 100644
index 000000000..999dd9700
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png
new file mode 100644
index 000000000..999dd9700
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png
new file mode 100644
index 000000000..b04d49f5b
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png
new file mode 100644
index 000000000..a319d366a
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
new file mode 100644
index 000000000..9af30f5bc
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png
new file mode 100644
index 000000000..9af30f5bc
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png
new file mode 100644
index 000000000..60f9208e5
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png
new file mode 100644
index 000000000..999dd9700
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
new file mode 100644
index 000000000..0a8a63928
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png
new file mode 100644
index 000000000..0a8a63928
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png
new file mode 100644
index 000000000..4b6e4d0cc
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png
new file mode 100644
index 000000000..f14cb2bfb
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png
new file mode 100644
index 000000000..4b6e4d0cc
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png
new file mode 100644
index 000000000..35445a4f8
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png
new file mode 100644
index 000000000..112238ef3
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png
new file mode 100644
index 000000000..0a2721fdc
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
new file mode 100644
index 000000000..c3d395e0a
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
new file mode 100644
index 000000000..5ff0ad594
--- /dev/null
+++ b/src-tauri/src/lib.rs
@@ -0,0 +1,500 @@
+use arboard::Clipboard;
+use rfd::{MessageButtons, MessageDialog, MessageLevel};
+use serde::{Deserialize, Serialize};
+use std::{
+ fs::{self, OpenOptions},
+ io::Read,
+ net::{TcpListener, UdpSocket},
+ path::{Path, PathBuf},
+ process::{Child, Command, Stdio},
+ sync::{
+ atomic::{AtomicBool, Ordering},
+ Mutex,
+ },
+ thread,
+ time::{Duration, Instant},
+};
+use tauri::{
+ menu::{Menu, MenuItem},
+ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
+ AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent,
+};
+use uuid::Uuid;
+
+fn boxed_error(message: impl Into) -> Box {
+ Box::new(std::io::Error::other(message.into()))
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+struct AppConfig {
+ #[serde(default = "default_host")]
+ host: String,
+ #[serde(default = "default_port")]
+ port: u16,
+ #[serde(default = "default_true")]
+ lan_enabled: bool,
+ #[serde(default = "default_cache")]
+ cache_max_bytes: u64,
+}
+
+fn default_host() -> String {
+ "0.0.0.0".to_string()
+}
+fn default_port() -> u16 {
+ 3000
+}
+fn default_true() -> bool {
+ true
+}
+fn default_cache() -> u64 {
+ 10 * 1024 * 1024 * 1024
+}
+
+impl Default for AppConfig {
+ fn default() -> Self {
+ Self {
+ host: default_host(),
+ port: default_port(),
+ lan_enabled: true,
+ cache_max_bytes: default_cache(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize, Default)]
+struct WindowPlacement {
+ x: i32,
+ y: i32,
+ width: u32,
+ height: u32,
+ maximized: bool,
+}
+
+struct DesktopState {
+ backend: Mutex