Skill: quanming1/canvas-workspace (version 1.4.4)

--- name: canvas-workspace description: > 画布工作区操作能力。当需要生成图片、编辑图片、将图片推送到画布、或处理用户画布标记时激活。 包含：(1) Qwen 图片生成/编辑案例脚本（文生图与编辑图分离，作为可复制的 MVP 模板）， (2) 画布操作 API（推送图片、查看状态、批量注入图片）， (3) 画布图片协议（用户标记/选中图片后的 JSON 文件解析）。 触发场景：用户要求生图、画图、修图、把图片放到画布上、或消息中包含画布图片文件 URL。 关键词匹配：当用户消息中出现以下关键词时应加载本 skill： 画布、canvas、生图、生成图片、画图、绘图、修图、编辑图片、图片编辑、 推送到画布、放到画布、添加到画布、inject、gen_image、标记、marker、 图生图、文生图、AI生图、设计图、海报、画布图片文件、canvas_images。 metadata: clawdbot: emoji: "🎨" requires: env: ["QWEN_TEXT_IMAGE_API_KEY"] bins: ["python3", "npx"] os: ["linux", "darwin", "win32"] primaryEnv: "QWEN_TEXT_IMAGE_API_KEY" files: ["scripts/*"] --- # 画布工作区 ## !! 激活后立即执行以下流程 **当此 skill 被激活时，你必须按顺序执行以下步骤，不要跳过任何一步。** ### 步骤 1：启动画布工作区 ```bash npx deepminer-claw-canvas@latest ``` **启动过程说明**（告知用户）： 1. 首次运行会自动安装 Python 依赖（可能需要 1-2 分钟） 2. 依赖安装完成后启动服务（可能需要 10-30 秒） 3. **看到 `Application startup complete` 才表示启动成功** 4. 启动成功后会自动打开浏览器 5. 默认地址：**http://localhost:39301/claw** 6. 如果端口被占用会自动切换到下一个可用端口，**以终端输出的实际地址为准** > 如需指定端口：`CLAW_PORT=8080 npx deepminer-claw-canvas@latest` **重要**： - 服务启动后保持终端运行 - 后续的 `gen`/`edit`/`push` 命令需要**在新终端中执行** - 如果服务端口不是默认的 39301，执行脚本时需指定：`CLAW_PORT=实际端口 npx deepminer-claw-canvas@latest gen ...` ### 步骤 2：收集图片模型环境变量 **主动向用户询问**图片模型配置。必须明确说明：**生图模型** 和 **编辑图模型** 是两种用途，建议分开配置。 同时提醒用户：**如果 Claw Agent 所用底模具备多模态能力（能看图），处理图片编辑、标记理解、附件理解时效果通常更好；但这不是强制要求，是否切换模型由用户自行决定。** 对用户这样说： > 画布的图片能力需要两类模型：**文生图模型** 和 **编辑图模型**，请分别提供对应的 API Key。 > > 目前项目内置了 Qwen 的案例脚本，可以直接使用。如果你有 Qwen（通义万象 / DashScope）的 API Key，只需提供一个 Key 即可同时用于文生图和编辑图。 > > 如果你想接入其他模型，可以参考内置脚本的写法，新写一个独立 Python 脚本。内置脚本在 npm 包内的 [REDACTED_SECRET] 目录。 > > 如果暂时没有 API Key，画布基础能力（查看状态、推送已有图片）仍可使用，但无法生成或编辑新图片。 ### 步骤 3：设置环境变量 用户提供变量后，**必须设置为系统永久环境变量**，不要设置临时变量（临时变量关闭终端后就失效）。 每类模型需要三个变量：**API_KEY**（必填）、**MODEL**（模型名）、**BASE_URL**（API 地址）。 #### 使用 Qwen 案例脚本（默认） Windows（设置永久用户环境变量）： ```powershell [Environment]::SetEnvironmentVariable("QWEN_TEXT_IMAGE_API_KEY", "用户提供的值", "User") [Environment]::SetEnvironmentVariable("QWEN_TEXT_IMAGE_MODEL", "qwen-image-2.0-pro", "User") [Environment]::SetEnvironmentVariable("QWEN_BASE_URL", "https://dashscope.aliyuncs.com/api/v1", "User") [Environment]::SetEnvironmentVariable("QWEN_EDIT_IMAGE_API_KEY", "用户提供的值", "User") [Environment]::SetEnvironmentVariable("QWEN_EDIT_IMAGE_MODEL", "qwen-image-edit", "User") ``` > 设置后需要**重启终端**才能生效。 Mac/Linux（追加到 ~/.bashrc 或 ~/.zshrc）： ```bash echo 'export QWEN_TEXT_IMAGE_API_KEY="用户提供的值"' >> ~/.bashrc echo 'export QWEN_TEXT_IMAGE_MODEL="qwen-image-2.0-pro"' >> ~/.bashrc echo 'export QWEN_BASE_URL="https://dashscope.aliyuncs.com/api/v1"' >> ~/.bashrc echo 'export QWEN_EDIT_IMAGE_API_KEY="用户提供的值"' >> ~/.bashrc echo 'export QWEN_EDIT_IMAGE_MODEL="qwen-image-edit"' >> ~/.bashrc source ~/.bashrc ``` > 如果文生图和编辑图使用同一个 Qwen Key，两个 API_KEY 填同一个值即可。 > `QWEN_BASE_URL` 有默认值，不设也行；如果用新加坡地域需改成 `https://dashscope-intl.aliyuncs.com/api/v1`。 #### 使用自定义模型 如果用户要接入其他生图/编辑图模型，需要： 1. 参考内置 Qwen 脚本（npm 包内 [REDACTED_SECRET] 目录），新写一个 Python 脚本； 2. 在脚本中定义该模型自己的环境变量（命名建议：`{PROVIDER}_TEXT_IMAGE_API_KEY`、`{PROVIDER}_TEXT_IMAGE_MODEL`、`{PROVIDER}_BASE_URL` 等）； 3. 设置永久环境变量。 示例（假设接入 Gemini 文生图，Windows）： ```powershell [Environment]::SetEnvironmentVariable("GEMINI_TEXT_IMAGE_API_KEY", "用户提供的值", "User") [Environment]::SetEnvironmentVariable("GEMINI_TEXT_IMAGE_MODEL", "gemini-3.1-flash-image", "User") [Environment]::SetEnvironmentVariable("GEMINI_BASE_URL", "https://api.example.com/v1", "User") ``` 设置完成后告知用户画布已就绪，可以开始使用。 **以上 3 步完成后，画布工作区已就绪。后续用户发送的生图/编辑请求，按下方「决策指引」处理。** --- ## 决策指引 | 用户意图 | 操作 | | --- | --- | | 文生图（Qwen） | `npx deepminer-claw-canvas gen --prompt "..."` | | 文生图（Gemini） | `npx deepminer-claw-canvas gen --prompt "..." --provider gemini` | | 标记编辑（消息含画布图片文件 URL） | `npx deepminer-claw-canvas edit --prompt "..." --markers-file "<画布图片文件URL>"` | | 自由编辑 / 图生图 / 多图融合 | `npx deepminer-claw-canvas edit --prompt "..." --raw-image <图片URL>` | | 推送已有图片到画布 | `npx deepminer-claw-canvas push --url "..."` | | 查看画布当前状态 | `Invoke-RestMethod -Uri "http://localhost:<端口>/api/canvas/sync/debug"` | ## 能力边界 | 允许 | 禁止 | | --- | --- | | 生成/编辑图片 | 删除画布上的图片 | | 查看画布状态 | 移动、缩放、旋转画布上的图片 | | 用 Qwen 案例脚本作为其他模型接入模板 | 在画布上添加标记（仅用户可操作） | ## Qwen 案例脚本 Qwen 脚本是 **MVP 样板**，不是统一 provider 框架。 如果未来要接别的模型： 1. 复制 Qwen 脚本结构； 2. 使用目标模型自己的 SDK； 3. 定义该模型自己的 ENV； 4. 保持相同的调用风格与输出 JSON（尽量一致）。 ### 工具模块 脚本目录下提供了可复用的工具模块，用户构建自定义脚本时可直接导入使用： | 模块 | 用途 | | --- | --- | | `image_to_base64.py` | 将图片地址（网络 URL 或本地路径）转为 base64 data URL | | `push_to_canvas.py` | 将图片推送到画布（远程 URL 会自动下载转为本地 URL） | **使用示例**（在自定义脚本中）： ```python from image_to_base64 import image_to_base64 from push_to_canvas import push_to_canvas # 图片转 base64，用于传给 AI 模型 base64_url = image_to_base64("https://example.com/image.png") # 推送图片到画布（远程 URL 自动转本地，避免跨域问题） element_id, local_url = push_to_canvas("https://example.com/result.png") ``` ### 1) 文生图脚本 ```bash # 使用 Qwen（默认） npx deepminer-claw-canvas gen --prompt "一张极简海报，蓝白配色" # 使用 Gemini npx deepminer-claw-canvas gen --prompt "一张极简海报" --provider gemini ``` | 参数 | 必填 | 说明 | | --- | --- | --- | | `--prompt` | 是 | 文生图提示词 | | `--size` | 否 | 输出尺寸，如 `1024*1024` | | `--provider` | 否 | 模型提供商（qwen/gemini），默认 qwen | 生成的图片默认推送到画布。 ### 2) 编辑图脚本 两种调用方式（互斥），生成的图片默认推送到画布： ```bash # 标记编辑：从画布图片文件读取原图/标记图（URL 从用户消息中获取） npx deepminer-claw-canvas edit --prompt "把<<标记点1>>修改为蓝色" --markers-file "<画布图片文件URL>" # 自由编辑：直接传原图（URL 从用户消息中获取） npx deepminer-claw-canvas edit --prompt "变成卡通风格" --raw-image "<图片URL>" # 自由编辑（多图） npx deepminer-claw-canvas edit --prompt "把图一的人物放到图二的背景上" --raw-image "img1.png" --raw-image "img2.png" ``` | 参数 | 必填 | 说明 | | --- | --- | --- | | `--prompt` | 是 | 编辑指令 | | `--markers-file` | 与 --raw-image 二选一 | 画布图片文件 URL 或本地路径（标记编辑模式） | | `--raw-image` | 与 --markers-file 二选一 | 原图 URL 或本地路径，可多次传入（自由编辑模式） | ### 3) 推送图片脚本 直接将已有图片 URL 推送到画布： ```bash npx deepminer-claw-canvas push --url "https://example.com/image.png" ``` ### 输出约定 脚本输出 JSON，格式尽量一致： ```json {"status":"ok","url":"https://...","local_url":"http://localhost:39301/uploads/...","element_id":"gen_xxx","pushed":true} ``` 说明： - `url`：AI 模型返回的原始图片 URL（可能是远程 OSS 地址） - `local_url`：推送到画布的本地 URL（远程图片会自动下载转存） - `element_id`：画布上的元素 ID - `pushed`：是否成功推送到画布，`false` 表示推送失败但图片生成/编辑本身已成功 ## 画布操作 API | 接口 | 用途 | | --- | --- | | `POST /api/canvas/sync/gen_image` | 推送图片到画布 | | `GET /api/canvas/sync/debug` | 查看画布状态 | | `POST /api/canvas/sync/inject_image` | 批量注入图片 | ### API 详情 #### POST /api/canvas/sync/gen_image 推送图片到画布。 ```bash curl -X POST http://localhost:<端口>/api/canvas/sync/gen_image \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/image.png"}' ``` #### GET /api/canvas/sync/debug 查看画布当前状态（图片列表、标记等）。 ```bash curl http://localhost:<端口>/api/canvas/sync/debug ``` ## 画布图片协议 用户在画布上选择图片或标记图片后发消息，消息末尾附带画布图片文件 URL。文件中包含所有与画布相关的图片信息。 消息示例： ``` 请把 <<标记点1>> 修改为蓝色，参考我选中的那张图片的配色 画布图片文件: http://localhost:<端口>/uploads/canvas_images_xxx.json ``` ### 文件结构 ```json { "canvas_markers": [ { "id": "img_B", "raw_image": "http://localhost:<端口>/uploads/original_B.png", "marked_image": "http://localhost:<端口>/uploads/marked_B.png", "markers": [1, 2] } ], "selected_images": [ { "id": "img_A", "raw_image": "http://localhost:<端口>/uploads/selected_A.png" } ] } ``` | 字段 | 含义 | | --- | --- | | `canvas_markers` | 有标记的图片列表（原图 + 标记图 + 标记点编号） | | `selected_images` | 用户选中但未标记的图片列表（仅原图） | | `raw_image` | 无标记的原始图 URL | | `marked_image` | 带彩色标记框的合成图 URL | | `markers` | 标记点编号列表，对应消息中的 `<<标记点N>>` | ### 处理规则 1. 从消息末尾提取画布图片文件 URL 2. **消息中有「画布图片文件」URL** → 直接传给 `edit --markers-file <URL>`，脚本内部会自动下载和解析 3. **消息中只有普通图片 URL**（无标记） → 调用 `edit --raw-image <URL>` 4. 两者都有 → 根据用户意图决定调用方式 ## 环境变量参考 ### Qwen 文生图 | 环境变量 | 何时必填 | 默认值 | | --- | --- | --- | | `QWEN_TEXT_IMAGE_API_KEY` | 使用 Qwen 文生图时 | — | | `QWEN_TEXT_IMAGE_MODEL` | 否 | `qwen-image-2.0-pro` | | `QWEN_BASE_URL` | 否 | `https://dashscope.aliyuncs.com/api/v1` | ### Qwen 编辑图 | 环境变量 | 何时必填 | 默认值 | | --- | --- | --- | | `QWEN_EDIT_IMAGE_API_KEY` | 使用 Qwen 编辑图时 | — | | `QWEN_EDIT_IMAGE_MODEL` | 否 | `qwen-image-edit` | ### Gemini 文生图 | 环境变量 | 何时必填 | 默认值 | | --- | --- | --- | | `GEMINI_TEXT_IMAGE_API_KEY` | 使用 Gemini 文生图时 | — | | `GEMINI_TEXT_IMAGE_MODEL` | 否 | `gemini-3.1-flash-image` | | `GEMINI_BASE_URL` | 否 | `https://api.mmw.ink` | ### 公共配置 | 环境变量 | 何时必填 | 默认值 | | --- | --- | --- | | `CANVAS_SERVER` | 否 | `http://localhost:<端口>`（默认 39301） | ## Windows 环境注意事项 - **`curl` 在 PowerShell 中是 `Invoke-WebRequest` 的别名**，参数不兼容。获取 HTTP 数据请使用 `Invoke-RestMethod -Uri "<URL>"`，不要用 `curl`。 - **`web_fetch` 等 MCP 工具无法访问 localhost 地址**，获取本地服务数据必须用命令行。 - 执行 shell 命令时使用 PowerShell 语法，不要使用 cmd 语法（`&` 串联命令在 PowerShell 中报错）。 - PowerShell 中文错误输出可能显示为乱码（GBK/UTF-8 编码冲突），遇到乱码时根据上下文推断含义，不要将乱码原文展示给用户。 ## External Endpoints | URL | 发送的数据 | |-----|-----------| | `https://dashscope.aliyuncs.com/api/v1` | Qwen 文生图/编辑图请求（prompt、图片 URL） | | `https://api.mmw.ink` | Gemini 文生图请求（prompt） | | `http://localhost:<端口>/api/canvas/sync/*` | 本地画布服务（图片推送、状态查询） | ## Security & Privacy - **会离开本机的数据**：用户输入的 prompt、待编辑图片的 URL（发送至 Qwen/Gemini API） - **不会离开本机的数据**：本地文件内容、环境变量值、画布状态数据 - 所有对外网络请求均通过 HTTPS 加密传输 - 本地画布服务仅监听 localhost，不暴露到外网 ## Model Invocation Note 本 Skill 可能由 OpenClaw 在对话中自主调用，属正常行为。 如不希望自动调用，可在 OpenClaw 设置中关闭该 Skill 的自动触发权限。 ## Trust Statement 使用本 Skill 时，您的 prompt 和图片 URL 将发送至阿里云 DashScope（Qwen）或第三方 Gemini 代理服务。 请仅在信任这些服务的前提下安装和使用本 Skill。

path: README.md
content: # Canvas Workspace Skill

画布工作区操作能力。支持 AI 生图、编辑图、将图片推送到画布。

## 功能

- **文生图**：使用 Qwen 或 Gemini 模型生成图片
- **编辑图**：基于标记或自由编辑现有图片
- **推送图片**：将任意图片 URL 推送到画布
- **画布状态**：查看当前画布上的图片列表

## 快速开始

```bash
# 启动画布工作区
npx deepminer-claw-canvas@latest

# 文生图
npx deepminer-claw-canvas gen --prompt "一张极简海报"

# 编辑图
npx deepminer-claw-canvas edit --prompt "变成卡通风格" --raw-image "image.png"

# 推送图片
npx deepminer-claw-canvas push --url "https://example.com/image.png"
```

## 环境变量

| 变量 | 用途 |
|------|------|
| `QWEN_TEXT_IMAGE_API_KEY` | Qwen 文生图 API Key |
| `QWEN_EDIT_IMAGE_API_KEY` | Qwen 编辑图 API Key |
| `GEMINI_TEXT_IMAGE_API_KEY` | Gemini 文生图 API Key |

详见 [SKILL.md](./SKILL.md) 中的完整文档。

## 许可

MIT


path: references/canvas-api.md
content: # 画布操作 API 参考

画布后端运行在 `http://localhost:<端口>`（默认 39301，可通过 `CLAW_PORT` 环境变量覆盖）。

## POST /api/canvas/sync/gen_image

将图片推送到画布，前端自动显示并定位。

```bash
curl -X POST http://localhost:<端口>/api/canvas/sync/gen_image \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/image.png"}'
```

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `url` | string | 是 | 图片公网 URL |
| `element_id` | string | 否 | 自定义元素 ID，不传则自动生成 |

响应：`{"success": true, "element_id": "gen_abc123", "sequence_id": 5}`

## GET /api/canvas/sync/debug

查看画布当前状态（有哪些图片、连接客户端等）。

```bash
curl http://localhost:<端口>/api/canvas/sync/debug
```

响应：

```json
{
  "sequence_id": 5,
  "canvasDataCount": 2,
  "canvasData": [
    {
      "id": "gen_abc123",
      "metadata": {
        "category": "image",
        "id": "gen_abc123",
        "src": "https://example.com/gen_xxx.png",
        "is_delete": false
      },
      "style": {
        "matrix": [1, 0, 0, 1, 400, 300]
      }
    }
  ],
  "connectedClients": ["client_a1b2"]
}
```

`canvasData` 数组中每个对象的字段：

| 字段 | 说明 |
|------|------|
| `id` | 图片唯一 ID |
| `metadata.category` | 固定 `"image"` |
| `metadata.src` | 图片 URL |
| `metadata.is_delete` | `true` 表示已删除（逻辑删除） |
| `style.matrix` | 变换矩阵 `[scaleX, skewY, skewX, scaleY, translateX, translateY]` |

## POST /api/canvas/sync/inject_image

批量注入图片到画布。

```bash
curl -X POST http://localhost:<端口>/api/canvas/sync/inject_image \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://example.com/a.png", "https://example.com/b.png"]}'
```

## POST /api/canvas/sync/reset

清空画布所有数据。

```bash
curl -X POST http://localhost:<端口>/api/canvas/sync/reset
```


path: scripts/gemini_generate_image.py
content: """Gemini 文生图脚本。通过 OpenAI 兼容 API 调用 Gemini 生图模型，返回图片并可选推送到画布。

SECURITY MANIFEST:
- Environment variables accessed: GEMINI_TEXT_IMAGE_API_KEY, GEMINI_TEXT_IMAGE_MODEL, GEMINI_BASE_URL, CANVAS_SERVER
- External endpoints called: https://api.mmw.ink/v1/chat/completions (Gemini API), http://localhost:*/api/canvas/sync/* (local canvas)
- Local files read: none
- Local files written: temporary image file (auto-deleted)
"""
import argparse
import base64
import json
import os
import tempfile
import urllib.request
import uuid

GEMINI_BASE_URL = os.environ.get("GEMINI_BASE_URL", "https://api.mmw.ink")
GEMINI_TEXT_IMAGE_API_KEY = os.environ.get("GEMINI_TEXT_IMAGE_API_KEY", "")
GEMINI_TEXT_IMAGE_MODEL = os.environ.get("GEMINI_TEXT_IMAGE_MODEL", "gemini-3.1-flash-image")
CANVAS_SERVER = os.environ.get("CANVAS_SERVER", "http://localhost:39301")


def generate_image(prompt: str, size: str | None = None) -> str:
    """调用 Gemini 兼容 API 生成图片，返回可访问的图片 URL 或 data URI。"""
    if not GEMINI_TEXT_IMAGE_API_KEY:
        raise RuntimeError("请设置环境变量 GEMINI_TEXT_IMAGE_API_KEY 后再使用 Gemini 文生图脚本")

    # 构建 OpenAI 兼容的 chat completion 请求
    url = f"{GEMINI_BASE_URL}/v1/chat/completions"
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {GEMINI_TEXT_IMAGE_API_KEY}",
    }

    messages = [
        {
            "role": "user",
            "content": prompt,
        }
    ]

    body = {
        "model": GEMINI_TEXT_IMAGE_MODEL,
        "messages": messages,
        "stream": False,
    }

    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(url, data=data, headers=headers, method="POST")

    try:
        with urllib.request.urlopen(req, timeout=300) as resp:
            result = json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Gemini API 调用失败: {e.code} - {error_body}")

    # 解析响应，提取图片
    choices = result.get("choices", [])
    if not choices:
        raise RuntimeError(f"Gemini API 返回为空: {json.dumps(result, ensure_ascii=False)}")

    message = choices[0].get("message", {})

    # 尝试多种响应格式
    # 格式1: content 是字符串，可能包含 markdown 图片或 base64
    content = message.get("content", "")
    if isinstance(content, str):
        # 检查是否有 markdown 图片链接 ![...](url)
        import re
        md_match = re.search(r'!\[.*?\]\((https?://[^\s\)]+)\)', content)
        if md_match:
            return md_match.group(1)

        # 检查是否有裸 URL
        url_match = re.search(r'(https?://\S+\.(?:png|jpg|jpeg|webp|gif))', content, re.IGNORECASE)
        if url_match:
            return url_match.group(1)

    # 格式2: content 是列表（multimodal 格式）
    if isinstance(content, list):
        for item in content:
            if isinstance(item, dict):
                if "image_url" in item:
                    img = item["image_url"]
                    if isinstance(img, dict):
                        return img.get("url", "")
                    return img
                if "image" in item:
                    return item["image"]

    # 格式3: 检查 message 中的 inline_data / parts（Gemini 原生格式适配）
    parts = message.get("parts", [])
    for part in parts:
        if isinstance(part, dict) and "inline_data" in part:
            inline = part["inline_data"]
            mime = inline.get("mime_type", "image/png")
            b64 = inline.get("data", "")
            if b64:
                # 上传到画布服务器获取 URL
                return _upload_base64_to_canvas(b64, mime)

    # 格式4: 如果 content 本身是 base64 编码的图片
    if isinstance(content, str) and len(content) > 1000:
        try:
            base64.b64decode(content[:100])
            return _upload_base64_to_canvas(content, "image/png")
        except Exception:
            pass

    raise RuntimeError(f"无法从 Gemini 响应中提取图片。响应: {json.dumps(result, ensure_ascii=False)[:500]}")


def _upload_base64_to_canvas(b64_data: str, mime_type: str) -> str:
    """将 base64 图片数据上传到画布服务器，返回可访问的 URL。"""
    ext = mime_type.split("/")[-1].replace("jpeg", "jpg")
    filename = f"gemini_{uuid.uuid4().hex[:8]}.{ext}"

    image_bytes = base64.b64decode(b64_data)

    # 用 multipart/form-data 上传
    boundary = uuid.uuid4().hex
    body = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="image"; filename="{filename}"\r\n'
        f"Content-Type: {mime_type}\r\n\r\n"
    ).encode("utf-8") + image_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8")

    req = urllib.request.Request(
        f"{CANVAS_SERVER}/api/upload/image",
        data=body,
        headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            result = json.loads(resp.read().decode("utf-8"))
            return result.get("url", "")
    except Exception:
        # fallback: 返回 data URI
        return f"data:{mime_type};base64,{b64_data}"


def main():
    parser = argparse.ArgumentParser(description="Gemini 文生图（默认推送到画布）")
    parser.add_argument("--prompt", required=True, help="文生图提示词")
    parser.add_argument("--size", default=None, help="输出尺寸（预留，当前未使用）")
    args = parser.parse_args()

    image_url = generate_image(args.prompt, args.size)
    result = {"status": "ok", "url": image_url}

    try:
        from push_to_canvas import push_to_canvas
        element_id, local_url = push_to_canvas(image_url)
        result["pushed"] = True
        result["local_url"] = local_url
        result["element_id"] = element_id
    except Exception as e:
        result["pushed"] = False
        result["push_error"] = str(e)

    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/image_to_base64.py
content: """将图片地址转换为 base64 data URL。

支持网络 URL 和本地文件路径。

SECURITY MANIFEST:
- Environment variables accessed: none
- External endpoints called: 用户指定的图片 URL（仅下载）
- Local files read: 用户指定的本地图片文件
- Local files written: none
"""
import argparse
import base64
import json
import mimetypes
import os
import urllib.request


def image_to_base64(image_path: str) -> str:
    """将图片地址转换为 base64 data URL。
    
    Args:
        image_path: 图片地址，支持网络 URL 或本地文件路径
        
    Returns:
        base64 data URL，格式为 data:image/xxx;base64,...
    """
    if image_path.startswith("data:"):
        return image_path

    if image_path.startswith(("http://", "https://")):
        req = urllib.request.Request(image_path, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(req, timeout=60) as resp:
            data = resp.read()
            content_type = resp.headers.get("Content-Type", "image/png")
            if ";" in content_type:
                content_type = content_type.split(";")[0].strip()
    elif os.path.isfile(image_path):
        mime_type, _ = mimetypes.guess_type(image_path)
        content_type = mime_type or "image/png"
        with open(image_path, "rb") as f:
            data = f.read()
    else:
        raise RuntimeError(f"无法识别的图片路径: {image_path}")

    encoded = base64.b64encode(data).decode("utf-8")
    return f"data:{content_type};base64,{encoded}"


def main():
    parser = argparse.ArgumentParser(description="将图片地址转换为 base64 data URL")
    parser.add_argument("--path", required=True, help="图片地址（网络 URL 或本地文件路径）")
    args = parser.parse_args()

    try:
        result = image_to_base64(args.path)
        print(json.dumps({"status": "ok", "base64": result}, ensure_ascii=False))
    except Exception as e:
        print(json.dumps({"status": "error", "error": str(e)}, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/push_to_canvas.py
content: """Push an image URL to the canvas.

SECURITY MANIFEST:
- Environment variables accessed: CANVAS_SERVER
- External endpoints called: http://localhost:*/api/canvas/sync/gen_image, http://localhost:*/api/upload/image (local canvas only)
- Local files read: none
- Local files written: none

NOTE: 远程图片会先下载再通过本地上传接口推送，确保画布图片可被正常导出（避免跨域污染）。
"""
import argparse
import json
import os
import urllib.request
import uuid
from urllib.parse import urlparse

CANVAS_SERVER = os.environ.get("CANVAS_SERVER", "http://localhost:39301")


def _is_local_url(url: str) -> bool:
    """判断是否为本地服务器 URL"""
    parsed = urlparse(url)
    return parsed.netloc.startswith("localhost") or parsed.netloc.startswith("127.0.0.1")


def _download_and_upload(image_url: str) -> str:
    """下载远程图片并上传到本地服务器，返回本地 URL"""
    req = urllib.request.Request(image_url, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(req, timeout=60) as resp:
        image_data = resp.read()
        content_type = resp.headers.get("Content-Type", "image/png")

    ext = ".png"
    if "jpeg" in content_type or "jpg" in content_type:
        ext = ".jpg"
    elif "webp" in content_type:
        ext = ".webp"
    elif "gif" in content_type:
        ext = ".gif"

    filename = f"gen_{uuid.uuid4().hex[:12]}{ext}"
    boundary = uuid.uuid4().hex
    body = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="image"; filename="{filename}"\r\n'
        f"Content-Type: {content_type}\r\n\r\n"
    ).encode("utf-8") + image_data + f"\r\n--{boundary}--\r\n".encode("utf-8")

    upload_req = urllib.request.Request(
        f"{CANVAS_SERVER}/api/upload/image",
        data=body,
        headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
        method="POST",
    )
    with urllib.request.urlopen(upload_req, timeout=30) as resp:
        result = json.loads(resp.read().decode("utf-8"))
        return result.get("url", image_url)


def push_to_canvas(image_url: str, element_id: str | None = None) -> tuple[str, str]:
    """推送图片到画布，返回 (element_id, final_url)"""
    if not element_id:
        element_id = f"gen_{uuid.uuid4().hex[:12]}"

    final_url = image_url
    if not _is_local_url(image_url):
        try:
            final_url = _download_and_upload(image_url)
        except Exception as e:
            print(f"[push_to_canvas] 下载上传失败，使用原始 URL: {e}")

    payload = json.dumps({"url": final_url, "element_id": element_id}).encode("utf-8")
    req = urllib.request.Request(
        f"{CANVAS_SERVER}/api/canvas/sync/gen_image",
        data=payload,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=10) as resp:
        resp.read()
    return element_id, final_url


def main():
    parser = argparse.ArgumentParser(description="Push an image URL to the canvas")
    parser.add_argument("--url", required=True, help="Image URL to push")
    parser.add_argument("--id", default=None, help="Optional element id")
    args = parser.parse_args()

    element_id, final_url = push_to_canvas(args.url, args.id)
    print(json.dumps({"status": "ok", "url": args.url, "local_url": final_url, "element_id": element_id}))


if __name__ == "__main__":
    main()


path: scripts/qwen_edit_image.py
content: """Qwen 编辑图脚本。

两种调用方式：
1. 标记编辑：--prompt + --markers-file（从 JSON 文件读取原图/标记图）
2. 自由编辑：--prompt + --raw-image（可多次传入，直接用用户 prompt）

生成的图片默认推送到画布。

用法：
    # 标记编辑
    py qwen_edit_image.py --prompt "把<<标记点1>>修改为蓝色" --markers-file "<画布图片文件URL>"

    # 自由编辑（单图）
    py qwen_edit_image.py --prompt "变成卡通风格" --raw-image "<图片URL>"

    # 自由编辑（多图）
    py qwen_edit_image.py --prompt "把图一的人物放到图二的背景上" --raw-image "img1.png" --raw-image "img2.png"

SECURITY MANIFEST:
- Environment variables accessed: QWEN_EDIT_IMAGE_API_KEY, QWEN_EDIT_IMAGE_MODEL, QWEN_BASE_URL, CANVAS_SERVER
- External endpoints called: https://dashscope.aliyuncs.com/api/v1 (Qwen API), http://localhost:*/api/canvas/sync/* (local canvas)
- Local files read: markers-file JSON (if provided as local path), raw-image files (if provided as local paths)
- Local files written: none
"""
import argparse
import json
import os
import urllib.request

QWEN_BASE_URL = os.environ.get("QWEN_BASE_URL", "https://dashscope.aliyuncs.com/api/v1")
QWEN_EDIT_IMAGE_API_KEY = os.environ.get("QWEN_EDIT_IMAGE_API_KEY", "")
QWEN_EDIT_IMAGE_MODEL = os.environ.get("QWEN_EDIT_IMAGE_MODEL", "qwen-image-edit-max-2026-01-16")
CANVAS_SERVER = os.environ.get("CANVAS_SERVER", "http://localhost:39301")


def build_marker_prompt(user_instruction: str) -> str:
    """标记编辑模式的专业提示词。"""
    return f"""Using the provided images (original image and dashed marked image):

The marked image has a colored dashed border, with colors limited to red, yellow, blue, or green, indicating the area within the dashed line that needs editing. {user_instruction}

The lower right corner of the colored border contains a colored circle with a number; the number corresponds to different modification logic.

Important: Do not include or retain any colored (red, yellow, blue, or green) dashed borders in the final output image. Colored dashed borders are for reference only and must be completely removed.

Important Notes:
- Understand the purpose of the dashed border and edit the area and objects it contains, paying attention to details.
- Remove all auxiliary dashed markers and numbers from the modified image, ensuring no trace remains.
- Keep everything outside the marked areas exactly the same.
- Preserve the original style, lighting, and composition.
- Ensure all changes blend naturally with the surrounding environment."""


def load_file(path: str) -> dict:
    """加载 JSON 文件（支持 URL 或本地路径）。"""
    if path.startswith(("http://", "https://")):
        with urllib.request.urlopen(path, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def download_as_base64(image_ref: str) -> str:
    """下载图片并转为 base64 data URL。"""
    from image_to_base64 import image_to_base64
    return image_to_base64(image_ref)


def call_qwen(prompt: str, image_refs: list[str]) -> str:
    """调用 Qwen 编辑图 API，返回结果图 URL。"""
    if not QWEN_EDIT_IMAGE_API_KEY:
        raise RuntimeError("请设置环境变量 QWEN_EDIT_IMAGE_API_KEY")

    from dashscope import MultiModalConversation
    import dashscope

    dashscope.base_http_api_url = QWEN_BASE_URL

    content = [{"image": download_as_base64(ref)} for ref in image_refs]
    content.append({"text": prompt})

    response = MultiModalConversation.call(
        [REDACTED_SECRET],
        model=QWEN_EDIT_IMAGE_MODEL,
        messages=[{"role": "user", "content": content}],
        stream=False,
        n=1,
        watermark=False,
    )

    status_code = getattr(response, "status_code", None) or response.get("status_code")
    if status_code != 200:
        code = getattr(response, "code", None) or response.get("code", "")
        message = getattr(response, "message", None) or response.get("message", "")
        raise RuntimeError(f"Qwen 编辑图调用失败: {code} - {message}")

    output = getattr(response, "output", None) or response.get("output", {})
    choices = getattr(output, "choices", None) or output.get("choices", [])
    if not choices:
        raise RuntimeError("Qwen 编辑图返回为空")

    msg = choices[0].get("message", {})
    for item in msg.get("content", []):
        if "image" in item:
            return item["image"]

    raise RuntimeError("Qwen 编辑图返回中没有图片 URL")


def _push_to_canvas(image_url: str) -> dict:
    """推送图片到画布，返回结果。"""
    try:
        from push_to_canvas import push_to_canvas
        element_id, local_url = push_to_canvas(image_url)
        return {"pushed": True, "element_id": element_id, "local_url": local_url}
    except Exception as e:
        return {"pushed": False, "push_error": str(e)}


def run_marker_edit(prompt: str, markers_file: str) -> list[dict]:
    """标记编辑模式：从 JSON 文件读取图片信息。"""
    data = load_file(markers_file)
    canvas_markers = data.get("canvas_markers", [])
    if not canvas_markers:
        raise RuntimeError("标记文件中没有 canvas_markers 数据")

    results = []
    for item in canvas_markers:
        raw_image = item.get("raw_image")
        marked_image = item.get("marked_image")
        if not raw_image or not marked_image:
            continue

        final_prompt = build_marker_prompt(prompt)
        image_url = call_qwen(final_prompt, [raw_image, marked_image])

        result = {"id": item.get("id"), "markers": item.get("markers", []), "status": "ok", "url": image_url}
        result.update(_push_to_canvas(image_url))
        results.append(result)

    return results


def run_free_edit(prompt: str, raw_images: list[str]) -> list[dict]:
    """自由编辑模式：直接用用户 prompt + 原图。"""
    image_url = call_qwen(prompt, raw_images)
    result = {"status": "ok", "url": image_url}
    result.update(_push_to_canvas(image_url))
    return [result]


def main():
    parser = argparse.ArgumentParser(description="Qwen 编辑图")
    parser.add_argument("--prompt", required=True, help="编辑指令")
    parser.add_argument("--markers-file", default=None, help="画布图片文件 URL 或本地路径（标记编辑模式）")
    parser.add_argument("--raw-image", action="append", dest="raw_images", default=None, help="原图 URL 或本地路径，可多次传入（自由编辑模式）")
    args = parser.parse_args()

    if args.markers_file and args.raw_images:
        raise RuntimeError("--markers-file 和 --raw-image 不能同时使用")
    if not args.markers_file and not args.raw_images:
        raise RuntimeError("必须指定 --markers-file 或 --raw-image")

    if args.markers_file:
        results = run_marker_edit(args.prompt, args.markers_file)
    else:
        results = run_free_edit(args.prompt, args.raw_images)

    output = results[0] if len(results) == 1 else results
    print(json.dumps(output, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/qwen_generate_image.py
content: """Qwen 文生图案例脚本。直接返回 Qwen 结果 URL，可选推送到画布；不上传 OSS。

SECURITY MANIFEST:
- Environment variables accessed: QWEN_TEXT_IMAGE_API_KEY, QWEN_TEXT_IMAGE_MODEL, QWEN_BASE_URL, CANVAS_SERVER
- External endpoints called: https://dashscope.aliyuncs.com/api/v1 (Qwen API), http://localhost:*/api/canvas/sync/* (local canvas)
- Local files read: none
- Local files written: none
"""
import argparse
import json
import os
import urllib.request
import uuid

QWEN_BASE_URL = os.environ.get("QWEN_BASE_URL", "https://dashscope.aliyuncs.com/api/v1")
QWEN_TEXT_IMAGE_API_KEY = os.environ.get("QWEN_TEXT_IMAGE_API_KEY", "")
QWEN_TEXT_IMAGE_MODEL = os.environ.get("QWEN_TEXT_IMAGE_MODEL", "qwen-image-2.0-pro")
CANVAS_SERVER = os.environ.get("CANVAS_SERVER", "http://localhost:39301")


def generate_image(prompt: str, size: str | None = None) -> str:
    if not QWEN_TEXT_IMAGE_API_KEY:
        raise RuntimeError("请设置环境变量 QWEN_TEXT_IMAGE_API_KEY 后再使用 Qwen 文生图脚本")

    from dashscope import MultiModalConversation
    import dashscope

    dashscope.base_http_api_url = QWEN_BASE_URL

    kwargs = {
        "api_key": QWEN_TEXT_IMAGE_API_KEY,
        "model": QWEN_TEXT_IMAGE_MODEL,
        "messages": [{"role": "user", "content": [{"text": prompt}]}],
        "stream": False,
        "n": 1,
        "watermark": False,
        "prompt_extend": True,
    }
    if size:
        kwargs["size"] = size

    response = MultiModalConversation.call(**kwargs)

    status_code = getattr(response, "status_code", None) or response.get("status_code")
    if status_code != 200:
        code = getattr(response, "code", None) or response.get("code", "")
        message = getattr(response, "message", None) or response.get("message", "")
        raise RuntimeError(f"Qwen 文生图调用失败: {code} - {message}")

    output = getattr(response, "output", None) or response.get("output", {})
    choices = getattr(output, "choices", None) or output.get("choices", [])
    if not choices:
        raise RuntimeError("Qwen 文生图返回为空")

    message = choices[0].get("message", {})
    content = message.get("content", [])
    for item in content:
        if "image" in item:
            return item["image"]

    raise RuntimeError("Qwen 文生图返回中没有图片 URL")


def main():
    parser = argparse.ArgumentParser(description="Qwen 文生图（默认推送到画布）")
    parser.add_argument("--prompt", required=True, help="文生图提示词")
    parser.add_argument("--size", default=None, help="输出尺寸，如 1024*1024")
    args = parser.parse_args()

    image_url = generate_image(args.prompt, args.size)
    result = {"status": "ok", "url": image_url}

    try:
        from push_to_canvas import push_to_canvas
        element_id, local_url = push_to_canvas(image_url)
        result["pushed"] = True
        result["local_url"] = local_url
        result["element_id"] = element_id
    except Exception as e:
        result["pushed"] = False
        result["push_error"] = str(e)

    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)