Notion Blog
opencode 图片识别 Skill(image-vision)安装与使用教程
技术分享1 分钟阅读

opencode 图片识别 Skill(image-vision)安装与使用教程

opencode 图片识别 Skill(image-vision)完整教程

一句话:给 opencode 装上「眼睛」。当你用的模型不支持读图时,用这个 Skill 把图片交给视觉 API,AI 就能「看见」截图、报错、PDF 并继续干活。

0. 先搞懂:它解决什么问题

很多 opencode 用户会遇到这个场景——发一张截图给 AI:

用户: 帮我看看这个报错

ERROR: Cannot read "image.png" (this model does not support image input)

原因是当前模型(如某些轻量模型)不支持图片输入。本 Skill 的解法:把图片提取出来 → 发给视觉 API(如通义千问 VL、GPT-4o)→ 拿到文字描述 → 主模型基于描述继续回答。

适合人群:使用无视觉能力模型的 opencode 用户;或任何想让 AI 看懂图片/PDF/截图的场景。

它是「给模型用的工具」,不是「给你用的软件」。装好后你什么都不用做——遇到图片时 AI 会自动调用它。

1. 文件清单与安装

本 Skill 共 5 个文件,完整代码在文末第 8 节,直接复制即可。目录结构:

image-vision/
├── SKILL.md              # Skill 说明(opencode 自动加载)
├── vision                # 统一入口命令
├── analyze_image.py      # 识别核心脚本
├── get_opencode_image.py # 从 opencode 数据库提取图片
└── config.json           # API 配置(多供应商档案)

安装:

mkdir -p ~/.agents/skills/image-vision
# 按第 8 节的代码逐个创建文件到该目录
chmod +x ~/.agents/skills/image-vision/vision

# 验证安装
bash ~/.agents/skills/image-vision/vision --help
注意:安装后需重启 opencode 会话,新 Skill 才会被加载。

2. 配置视觉 API(最关键的一步)

编辑 ~/.agents/skills/image-vision/config.json。三个方案任选其一:

2.1 方案 A:阿里云百炼(推荐,国内快,新用户送免费额度)

① 注册百炼控制台 → 开通 DashScope 服务 → 获取 API Key(sk- 开头)

② config.json 写入:

{
  "default_profile": "dashscope",
  "profiles": {
    "dashscope": {
      "url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
      "key": "你的API-KEY",
      "model": "qwen-vl-max"
    }
  }
}

2.2 方案 B:OpenAI(海外用户)

{
  "default_profile": "openai",
  "profiles": {
    "openai": {
      "url": "https://api.openai.com/v1/chat/completions",
      "key": "sk-...",
      "model": "gpt-4o-mini"
    }
  }
}

2.3 方案 C:本地 ollama(完全免费,离线)

{
  "default_profile": "ollama-local",
  "profiles": {
    "ollama-local": {
      "ollama": true,
      "url": "http://127.0.0.1:11434",
      "ollama_model": "qwen3.5:4b-q4_K_M"
    }
  }
}

或什么都不配——脚本会在未配置 API 时自动尝试本地 ollama。

不想改文件?用环境变量也行:export VISION_API_URL=... VISION_API_KEY=... VISION_MODEL=...(优先级高于配置文件)

3. 使用方式(两种)

3.1 被动使用(推荐,零操作)

直接在 opencode 对话里发图片/截图/PDF,AI 检测到模型不支持读图时,会自动调用 Skill 完成识别,你无感知。

3.2 手动测试(验证配置是否生效)

# 识别图片文件
bash ~/.agents/skills/image-vision/vision test.png "描述这张图"

# 剪贴板截图(先 Ctrl+C 复制图片)
bash ~/.agents/skills/image-vision/vision --clipboard

# 输出示例:
# [info] 识别耗时 3.2s (provider=dashscope, kind=openai)
# 这是一张登录页面截图,包含用户名/密码输入框和登录按钮...

4. 常用命令速查

vision <图片路径> [提示词]                # 单图识别
vision --clipboard [提示词]               # 剪贴板截图
vision https://example.com/a.png          # 远程 URL
vision report.pdf --pdf-page 3            # PDF 指定页(需 poppler-utils)
vision a.png b.jpg --prompt "对比差异"     # 多图对比
vision --json                             # 结构化 JSON 输出
vision --profile dashscope                # 指定供应商档案
vision --no-cache                         # 跳过缓存强制重新识别
vision --get                              # 从 opencode 数据库提取最新图片
vision --get --all                        # 列出最近图片

提示词示例:「识别这张图里的报错信息」「提取图片中的表格数据」「对比两张图的差异」

5. 工作原理(30 秒看懂)

提取图片:路径 / 剪贴板 / URL 下载 / PDF 转 PNG / opencode 数据库
调用视觉 API:按 config.json 档案选供应商,把图片 base64 编码后发送
返回描述:主模型拿到文字结果继续回答你的问题

识别结果按图片哈希缓存(cache.json),同一张图重复提问直接秒回、不重复计费。

6. 常见问题排查

Q1: 剪贴板识别报错

Linux 需要 xclip(X11)或 wl-paste(Wayland):sudo apt install xclip。装不了就用图片路径方式。

Q2: PDF 识别失败

需要 poppler-utils:sudo apt install poppler-utils(macOS: brew install poppler

Q3: 提示模型 / API Key 相关错误

检查 config.json 的 model 字段是否与供应商支持的一致(qwen-vl-max 需在百炼开通),Key 是否复制完整无空格。

Q4: 提示找不到图片文件

脚本会自动在截图目录/桌面/下载/临时目录按文件名搜索,仍找不到会尝试剪贴板。可加 VISION_SEARCH_DIRS 环境变量扩展搜索路径。

Q5: 识别很慢(几十秒)

首次调用 ollama 需要加载模型(几十秒~分钟),属正常。远程 API 一般 3~10 秒。超时可用 --timeout 240 加大。

7. 环境变量完整列表

VISION_CONFIG        配置文件路径(默认 skill 目录 config.json)
VISION_CACHE         缓存文件路径
VISION_API_URL       覆盖 API 地址
VISION_API_KEY       覆盖 API Key
VISION_MODEL         覆盖模型名
VISION_PROFILE       默认档案名
VISION_OLLAMA_MODEL  ollama 模型名
VISION_MAX_TOKENS    最大输出 token
VISION_TEMPERATURE   采样温度
VISION_SEARCH_DIRS   冒号分隔的图片搜索目录

8. 完整代码(复制即用)

以下 5 个文件为完整源码,逐个复制保存到 ~/.agents/skills/image-vision/ 即可。

8.1 vision — 统一入口命令(保存为 vision,无扩展名)

#!/usr/bin/env bash
# image-vision 统一入口:自动定位 skill 目录,无需硬编码路径。
# 用法:
#   vision <图片路径|URL|PDF> [提示词]     # 识别图片(单图/多图/PDF)
#   vision --clipboard [提示词]            # 识别剪贴板图片
#   vision --get [--all|--index N]        # 从 opencode 数据库提取图片
#   vision --help                          # 本帮助
# 其余参数透传给 analyze_image.py。
set -euo pipefail

# 自动定位本 skill 目录(兼容符号链接)
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
  SELF="$(readlink -f "$0")"
else
  SELF="$0"
fi
SKILL_DIR="$(cd "$(dirname "$SELF")" && pwd)"
ANALYZE="$SKILL_DIR/analyze_image.py"
GET="$SKILL_DIR/get_opencode_image.py"

usage() {
  cat <<'EOF'
用法:
  vision <图片路径|URL|PDF> [提示词]   识别图片(单图/多图/PDF)
  vision --clipboard [提示词]          识别剪贴板图片
  vision --get [--all|--index N]      从 opencode 数据库提取图片
  vision --help                       本帮助
其他参数透传给 analyze_image.py(--json / --profile / --pdf-page 等)。
EOF
}

if [[ $# -eq 0 ]]; then
  usage
  exit 0
fi

case "$1" in
  --help|-h)
    usage
    exit 0
    ;;
  --get)
    shift
    exec python3 "$GET" "$@"
    ;;
  *)
    exec python3 "$ANALYZE" "$@"
    ;;
esac

8.2 config.json — API 配置模板

{
  "default_profile": "dashscope",
  "profiles": {
    "dashscope": {
      "url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
      "key": "sk-你的API-KEY",
      "model": "qwen-vl-max"
    },
    "openai": {
      "url": "https://api.openai.com/v1/chat/completions",
      "key": "sk-...",
      "model": "gpt-4o-mini"
    },
    "ollama-local": {
      "ollama": true,
      "url": "http://127.0.0.1:11434",
      "ollama_model": "qwen3.5:4b-q4_K_M"
    }
  }
}

8.3 analyze_image.py — 识别核心脚本(505 行)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""image-vision skill helper: 把图片/PDF/URL 交给视觉 API(OpenAI 兼容 / 本地 ollama)识别并输出描述。

用法:
    python analyze_image.py <图片路径> [提示词]                    # 单图(兼容旧用法)
    python analyze_image.py a.png b.jpg --prompt "..."            # 多图一次调用
    python analyze_image.py --clipboard [提示词]                   # 剪贴板图片
    python analyze_image.py https://example.com/a.png [提示词]     # 远程 URL(自动下载)
    python analyze_image.py report.pdf [提示词]                    # PDF(提取首页,需 poppler-utils)
    python analyze_image.py a.png --json                           # 结构化输出
    python analyze_image.py a.png --profile dashscope              # 指定配置档案

图片路径不存在时,自动在常见目录(截图/临时/当前目录)按文件名搜索;
仍找不到且剪贴板有图片时,自动回退读取剪贴板。

配置优先级(从高到低):
    1. 命令行 --profile
    2. 环境变量: VISION_PROFILE / VISION_API_URL / VISION_API_KEY / VISION_MODEL
                 / VISION_OLLAMA_MODEL / VISION_MAX_TOKENS / VISION_TEMPERATURE
                 / VISION_CONFIG(配置文件路径) / VISION_SEARCH_DIRS(冒号分隔搜索目录)
    3. 本脚本同目录 config.json(支持 profiles 多档案,或旧的平铺 url/key/model)

url 为空或 profile 标记 ollama 时回退本地 ollama (默认 http://127.0.0.1:11434)。
"""

import argparse
import base64
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.request

for _s in (sys.stdout, sys.stderr):
    try:
        _s.reconfigure(encoding="utf-8", errors="replace")
    except Exception:
        pass

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.environ.get("VISION_CONFIG") or os.path.join(SCRIPT_DIR, "config.json")
CACHE_PATH = os.environ.get("VISION_CACHE") or os.path.join(SCRIPT_DIR, "cache.json")
CACHE_MAX = 100

DEFAULT_PROMPT = "Describe this image in detail."
OLLAMA_DEFAULT_URL = "http://127.0.0.1:11434"
OLLAMA_DEFAULT_MODEL = "qwen3.5:4b-q4_K_M"
DEFAULT_MAX_TOKENS = 2048
DEFAULT_TEMPERATURE = 0.2

IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".avif")
MIME_MAP = {
    "png": "image/png",
    "jpg": "image/jpeg",
    "jpeg": "image/jpeg",
    "webp": "image/webp",
    "gif": "image/gif",
    "bmp": "image/bmp",
    "avif": "image/avif",
}


# ---------------------------------------------------------------- 配置

def load_config():
    cfg = {}
    if os.path.exists(CONFIG_PATH):
        try:
            with open(CONFIG_PATH, "r", encoding="utf-8") as f:
                loaded = json.load(f)
            if isinstance(loaded, dict):
                cfg = loaded
        except Exception as e:
            print(f"[warn] config 读取失败({CONFIG_PATH}): {e}", file=sys.stderr)
    return cfg


def resolve_profile(cfg, profile_name):
    """解析配置档案,返回 (prof, kind, name)。kind: 'openai' | 'ollama'"""
    profiles = {}
    for k, v in (cfg.get("profiles") or {}).items():
        if isinstance(v, dict):
            profiles[str(k)] = dict(v)
    # 旧平铺格式兼容:作为 default 档案
    flat = {}
    for k in ("url", "key", "model", "auth_header", "auth_prefix", "ollama", "ollama_model", "max_tokens", "temperature"):
        v = cfg.get(k)
        if v is not None and str(v) != "":
            flat[k] = v
    if flat and "default" not in profiles:
        profiles["default"] = flat

    name = profile_name or os.environ.get("VISION_PROFILE", "") or str(cfg.get("default_profile") or "default")
    prof = dict(profiles.get(name) or profiles.get("default") or {})

    # 环境变量覆盖
    for env_k, cfg_k in (("VISION_API_URL", "url"), ("VISION_API_KEY", "key"), ("VISION_MODEL", "model")):
        v = os.environ.get(env_k, "").strip()
        if v:
            prof[cfg_k] = v
    v = os.environ.get("VISION_OLLAMA_MODEL", "").strip()
    if v:
        prof["ollama_model"] = v

    url = str(prof.get("url") or "").strip()
    if (not url
            or str(prof.get("ollama", "")).lower() in ("1", "true", "yes")
            or "/api/generate" in url
            or "11434" in url):
        kind = "ollama"
    else:
        kind = "openai"
    return prof, kind, name


# ---------------------------------------------------------------- 输入处理

def search_dirs():
    dirs = []
    env = os.environ.get("VISION_SEARCH_DIRS")
    if env:
        dirs += [d for d in env.split(":") if d]
    dirs += [
        os.path.expanduser("~/Pictures/Screenshots"),
        os.path.expanduser("~/Pictures"),
        os.path.expanduser("~/Desktop"),
        os.path.expanduser("~/Downloads"),
        os.environ.get("TMPDIR", "/tmp"),
        "/tmp",
        os.getcwd(),
    ]
    return dirs


def search_image(name):
    """按文件名在常见目录中查找图片,返回找到的路径或 None。"""
    base = os.path.basename(name)
    for d in search_dirs():
        if not d or not os.path.isdir(d):
            continue
        hit = os.path.join(d, base)
        if os.path.isfile(hit):
            return hit
    return None


def download(url):
    """下载远程图片到临时文件,返回本地路径或 None。"""
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (image-vision)"})
        with urllib.request.urlopen(req, timeout=60) as resp:
            data = resp.read()
        ext = os.path.splitext(url.split("?")[0])[1].lower() or ".png"
        if ext not in IMAGE_EXTS:
            ext = ".png"
        fd, tmp = tempfile.mkstemp(suffix=ext, prefix="image-vision-")
        with os.fdopen(fd, "wb") as f:
            f.write(data)
        return tmp
    except Exception as e:
        print(f"[warn] URL 下载失败 {url}: {e}", file=sys.stderr)
        return None


def data_url_to_file(src):
    """把 data:image/...;base64,... 输入落盘为临时文件。"""
    try:
        header, _, raw = src.partition(",")
        if "png" in header:
            ext = ".png"
        elif "jpeg" in header or "jpg" in header:
            ext = ".jpg"
        elif "webp" in header:
            ext = ".webp"
        elif "gif" in header:
            ext = ".gif"
        else:
            ext = ".png"
        fd, tmp = tempfile.mkstemp(suffix=ext, prefix="image-vision-")
        with os.fdopen(fd, "wb") as f:
            f.write(base64.b64decode(raw))
        return tmp
    except Exception as e:
        print(f"[warn] data URL 解码失败: {e}", file=sys.stderr)
        return None


def pdf_to_png(path, page=1):
    """用 pdftoppm(poppler-utils) 把 PDF 指定页转成 PNG,返回路径或 None。"""
    try:
        fd, tmp = tempfile.mkstemp(suffix=".png", prefix="image-vision-pdf-")
        os.close(fd)
        prefix = tmp[:-4]  # 去掉 .png
        cmd = ["pdftoppm", "-png", "-r", "150", "-f", str(page), "-l", str(page), path, prefix]
        r = subprocess.run(cmd, capture_output=True, timeout=180)
        if r.returncode != 0:
            print(f"[warn] pdftoppm 失败: {r.stderr.decode(errors='replace')[:200]}", file=sys.stderr)
            return None
        import glob

        hits = sorted(glob.glob(prefix + "*.png"))
        return hits[0] if hits else None
    except FileNotFoundError:
        print("[warn] 未找到 pdftoppm,请安装 poppler-utils (apt install poppler-utils)", file=sys.stderr)
        return None
    except Exception as e:
        print(f"[warn] PDF 转图失败: {e}", file=sys.stderr)
        return None


def resolve_source(src, pdf_page):
    """把输入(路径/URL/data URL/PDF)解析为本地图片路径,失败返回 None。"""
    if src.startswith(("http://", "https://")):
        return download(src)
    if src.startswith("data:"):
        return data_url_to_file(src)
    if src.lower().endswith(".pdf"):
        return pdf_to_png(src, pdf_page)
    if os.path.exists(src):
        return src
    found = search_image(src)
    if found:
        print(f"[info] 原始路径不存在,已在 {found} 找到同名文件", file=sys.stderr)
        return found
    return None


def grab_clipboard(save_to):
    """从剪贴板读取图片并保存到 save_to,返回保存路径;无图片返回 None。"""
    tmp = save_to
    if os.path.exists(tmp):
        return tmp

    # 优先使用 xclip(X11)
    for cmd in (
        ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
        ["xclip", "-selection", "clipboard", "-o"],
    ):
        try:
            out = subprocess.run(cmd, capture_output=True, timeout=5)
            if out.returncode == 0 and out.stdout:
                with open(tmp, "wb") as f:
                    f.write(out.stdout)
                return tmp
        except Exception:
            continue

    # Wayland wl-paste
    try:
        out = subprocess.run(["wl-paste", "-t", "image/png"], capture_output=True, timeout=5)
        if out.returncode == 0 and out.stdout:
            with open(tmp, "wb") as f:
                f.write(out.stdout)
            return tmp
    except Exception:
        pass

    # PIL 兜底(主要支持 Windows/macOS)
    try:
        from PIL import ImageGrab
    except Exception:
        print("[warn] 未安装 xclip/wl-paste,且 PIL 剪贴板不可用(Linux 无 X 支持时)。", file=sys.stderr)
        return None
    try:
        img = ImageGrab.grabclipboard()
        if img is None:
            return None
        if isinstance(img, list):
            for p in img:
                if isinstance(p, str) and os.path.isfile(p) and os.path.splitext(p)[1].lower() in IMAGE_EXTS:
                    return p
            return None
        img = img.convert("RGB")
        img.save(tmp, "PNG")
        return tmp
    except Exception as e:
        print(f"[warn] 剪贴板图片保存失败: {e}", file=sys.stderr)
        return None


# ---------------------------------------------------------------- API 调用

def call_openai_compatible(prof, image_data_urls, prompt, timeout):
    url = str(prof.get("url") or "").rstrip("/")
    if not url.endswith("/chat/completions"):
        url = url + "/chat/completions"
    headers = {"Content-Type": "application/json"}
    key = str(prof.get("key") or "").strip()
    if key:
        auth_header = str(prof.get("auth_header") or "Authorization")
        prefix = prof.get("auth_prefix")
        if prefix is None:
            prefix = "Bearer "
        headers[auth_header] = (str(prefix) + key).strip()
    try:
        temperature = float(os.environ.get("VISION_TEMPERATURE") or prof.get("temperature") or DEFAULT_TEMPERATURE)
    except ValueError:
        temperature = DEFAULT_TEMPERATURE
    try:
        max_tokens = int(os.environ.get("VISION_MAX_TOKENS") or prof.get("max_tokens") or DEFAULT_MAX_TOKENS)
    except ValueError:
        max_tokens = DEFAULT_MAX_TOKENS
    payload = {
        "model": prof.get("model"),
        "messages": [
            {
                "role": "user",
                "content": [{"type": "text", "text": prompt}]
                + [{"type": "image_url", "image_url": {"url": u}} for u in image_data_urls],
            }
        ],
        "temperature": temperature,
        "max_tokens": max_tokens,
    }
    req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        data = json.loads(resp.read().decode("utf-8"))
    try:
        return data["choices"][0]["message"]["content"]
    except (KeyError, IndexError, TypeError):
        return json.dumps(data, ensure_ascii=False)


def call_ollama(prof, image_data_urls, prompt, timeout):
    model = (
        os.environ.get("VISION_OLLAMA_MODEL", "").strip()
        or str(prof.get("ollama_model") or "").strip()
        or OLLAMA_DEFAULT_MODEL
    )
    url = (str(prof.get("url") or "").strip().rstrip("/") or OLLAMA_DEFAULT_URL)
    if not url.endswith("/api/generate"):
        url = url + "/api/generate"
    raw_images = [u.split(",", 1)[1] for u in image_data_urls]
    payload = {
        "model": model,
        "prompt": prompt,
        "images": raw_images,
        "stream": False,
        "options": {"temperature": DEFAULT_TEMPERATURE},
    }
    req = urllib.request.Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        data = json.loads(resp.read().decode("utf-8"))
    return data.get("response") or json.dumps(data, ensure_ascii=False)


# ---------------------------------------------------------------- 缓存

def load_cache():
    try:
        with open(CACHE_PATH, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def save_cache(cache):
    try:
        items = sorted(cache.items(), key=lambda kv: kv[1].get("ts", 0), reverse=True)[:CACHE_MAX]
        with open(CACHE_PATH, "w", encoding="utf-8") as f:
            json.dump(dict(items), f, ensure_ascii=False, indent=2)
    except Exception:
        pass


def file_hash(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def encode_image(path):
    ext = os.path.splitext(path)[1].lower().lstrip(".")
    mime = MIME_MAP.get(ext, "image/png")
    with open(path, "rb") as f:
        b64 = base64.b64encode(f.read()).decode("utf-8")
    return f"data:{mime};base64,{b64}"


# ---------------------------------------------------------------- main

def main():
    ap = argparse.ArgumentParser(description="image vision helper (通用: 图片/URL/PDF/剪贴板/多图)")
    ap.add_argument("inputs", nargs="*", help="图片/URL/PDF 路径,可多个(多图一次调用)")
    ap.add_argument("prompt", nargs="?", default=None, help="[旧用法] 第二个位置参数为提示词(若它不像文件)")
    ap.add_argument("--prompt", dest="prompt_opt", default=None, help="识别提示词")
    ap.add_argument("--clipboard", action="store_true", help="从剪贴板读取图片(复制好的截图)")
    ap.add_argument("--profile", default=None, help="配置档案名(config.json 的 profiles 键)")
    ap.add_argument("--json", action="store_true", help="输出 JSON 结构化结果")
    ap.add_argument("--timeout", type=int, default=240, help="请求超时秒数(默认240)")
    ap.add_argument("--pdf-page", type=int, default=1, help="PDF 提取第几页(默认1)")
    ap.add_argument("--no-cache", action="store_true", help="跳过结果缓存")
    args = ap.parse_args()

    prompt = args.prompt_opt or args.prompt or DEFAULT_PROMPT
    inputs = list(args.inputs)

    # 旧用法兼容1: analyze_image.py <图片> <提示词>
    if args.prompt_opt is None and args.prompt is None and len(inputs) >= 2:
        second = inputs[1]
        if not (os.path.exists(second) or second.startswith(("http://", "https://", "data:")) or second.lower().endswith(".pdf")):
            prompt = second
            inputs = [inputs[0]]
    # 旧用法兼容2: analyze_image.py --clipboard <提示词>
    if args.clipboard and args.prompt_opt is None and args.prompt is None and len(inputs) == 1:
        prompt = inputs[0]
        inputs = []

    image_paths = []
    if args.clipboard or not inputs:
        tmp = os.path.join(tempfile.gettempdir(), "image-vision-clipboard.png")
        cb = grab_clipboard(tmp)
        if cb is None:
            print("ERROR: 剪贴板中没有图片,且未提供图片路径。请先复制图片(Ctrl+C)或传路径/URL。", file=sys.stderr)
            print("提示: 本机为 Linux,可用 xclip/wl-paste 读取剪贴板,或直接用图片路径。", file=sys.stderr)
            sys.exit(2)
        print(f"[info] 已从剪贴板获取图片: {cb}", file=sys.stderr)
        image_paths.append(cb)
    else:
        for src in inputs:
            p = resolve_source(src, args.pdf_page)
            if p:
                image_paths.append(p)
            else:
                print(f"[warn] 无法解析输入(不存在/下载失败): {src}", file=sys.stderr)
        if not image_paths:
            # 兜底:尝试剪贴板
            tmp = os.path.join(tempfile.gettempdir(), "image-vision-clipboard.png")
            cb = grab_clipboard(tmp)
            if cb:
                print(f"[info] 输入均不可用,已回退读取剪贴板图片: {cb}", file=sys.stderr)
                image_paths.append(cb)
            else:
                print("ERROR: 没有可用图片输入。请提供图片路径/URL/PDF,或复制图片后重试(--clipboard)。", file=sys.stderr)
                sys.exit(2)

    cfg = load_config()
    prof, kind, prof_name = resolve_profile(cfg, args.profile)
    image_data_urls = [encode_image(p) for p in image_paths]

    def emit(res, cached=False):
        if args.json:
            print(json.dumps({
                "result": res,
                "cached": cached,
                "provider": prof_name,
                "kind": kind,
                "model": prof.get("model") or prof.get("ollama_model") or "",
                "images": image_paths,
                "prompt": prompt,
                "elapsed": round(elapsed, 2),
            }, ensure_ascii=False, indent=2))
        else:
            print(res)

    elapsed = 0.0
    if not args.no_cache:
        hashes = "|".join(file_hash(p) for p in image_paths)
        cache_key = f"{hashes}|{prof.get('model') or prof.get('ollama_model') or ''}|{prompt}"
        cache = load_cache()
        hit = cache.get(cache_key)
        if hit and hit.get("result"):
            print("[info] 命中缓存(同一图片已识别过),跳过 API 调用", file=sys.stderr)
            emit(hit["result"], cached=True)
            return

    t0 = time.time()
    if kind == "openai":
        if not prof.get("model"):
            print(f"ERROR: profile '{prof_name}' 缺少 model。请在 config.json 中配置。", file=sys.stderr)
            sys.exit(1)
        result = call_openai_compatible(prof, image_data_urls, prompt, args.timeout)
    else:
        try:
            result = call_ollama(prof, image_data_urls, prompt, args.timeout)
        except Exception as e:
            print(f"ERROR: 本地 ollama 调用失败: {e}", file=sys.stderr)
            print(f"提示: 可配置 config.json 或环境变量 VISION_API_URL/KEY/MODEL 使用远程视觉 API。", file=sys.stderr)
            sys.exit(1)
    elapsed = time.time() - t0
    print(f"[info] 识别耗时 {elapsed:.1f}s (provider={prof_name}, kind={kind})", file=sys.stderr)

    if not args.no_cache:
        cache[cache_key] = {"result": result, "ts": time.time()}
        save_cache(cache)
    emit(result)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(130)

8.4 get_opencode_image.py — 从 opencode 数据库提取图片(93 行)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""从 opencode 数据库提取用户粘贴的最新图片,保存到文件。

用法:
    python get_opencode_image.py [输出路径] [--all] [--index N] [--db PATH]

说明:
    - 默认提取最近一张图片 (image/* file part),保存为 /tmp/opencode_image.<ext>
    - --all: 列出最近 10 张图片的序号/时间/大小
    - 数据库路径可用 --db 或环境变量 OPENCODE_DB 覆盖(默认 ~/.local/share/opencode/opencode.db)
    - 兼容 data URL / file:// / 本地路径三种存储形式
    - 配合 image-vision skill: python analyze_image.py <提取出的路径>
"""

import argparse
import base64
import json
import os
import shutil
import sqlite3
import sys

DEFAULT_DB = os.path.expanduser("~/.local/share/opencode/opencode.db")
OUT_DIR = "/tmp/opencode"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("output", nargs="?", default=None, help="输出文件路径(默认 /tmp/opencode_image.<ext>)")
    ap.add_argument("--all", action="store_true", help="列出最近图片而非提取")
    ap.add_argument("--index", type=int, default=0, help="提取第几张(0=最新,1=次新...)")
    ap.add_argument("--db", default=None, help=f"opencode 数据库路径(默认 {DEFAULT_DB},可用环境变量 OPENCODE_DB)")
    args = ap.parse_args()

    db_path = args.db or os.environ.get("OPENCODE_DB") or DEFAULT_DB
    if not os.path.exists(db_path):
        print(f"ERROR: 数据库不存在: {db_path}", file=sys.stderr)
        sys.exit(1)

    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    rows = conn.execute("SELECT id, session_id, time_created, data FROM part").fetchall()

    images = []
    for r in rows:
        try:
            d = json.loads(r["data"])
        except Exception:
            continue
        if d.get("type") == "file" and str(d.get("mime", "")).startswith("image"):
            images.append({"part": d, "time": r["time_created"]})

    images.reverse()  # 旧的在前,新的在后
    if not images:
        print("ERROR: 数据库中没有图片 (image/* file part)", file=sys.stderr)
        sys.exit(1)

    if args.all:
        print(f"共 {len(images)} 张图片:")
        for i, im in enumerate(images[-10:]):
            d = im["part"]
            url = d.get("url", "")
            size = len(url.split(",", 1)[1]) if "," in url else 0
            print(f"  [{i}] {im['time']} | {d.get('mime')} | {d.get('filename')} | ~{size // 1024}KB")
        return

    idx = min(args.index, len(images) - 1)
    d = images[idx]["part"]
    url = d.get("url", "")
    ext_map = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif", "image/avif": "avif", "image/bmp": "bmp"}
    ext = ext_map.get(d.get("mime", ""), "png")
    out = args.output or os.path.join(OUT_DIR, f"opencode_image.{ext}")
    os.makedirs(os.path.dirname(out), exist_ok=True)

    if "," in url:
        # data URL(粘贴/上传的 base64 图片)
        raw = url.split(",", 1)[1]
        with open(out, "wb") as f:
            f.write(base64.b64decode(raw))
    elif url.startswith("file://"):
        shutil.copyfile(url[len("file://"):], out)
    elif os.path.exists(url):
        # 本地文件路径存储
        shutil.copyfile(url, out)
    else:
        print(f"ERROR: 图片 url 格式异常: {url[:100]}", file=sys.stderr)
        sys.exit(1)
    print(out)


if __name__ == "__main__":
    main()

8.5 SKILL.md — Skill 说明文档(opencode 自动加载)

---
name: image-vision
description: 图片/截图/PDF/URL/多图 视觉识别。Use when the current model cannot read image input — the Read tool fails with "does not support image input", or the user attaches/mentions a 图片/截图/照片/screenshot/diagram (including 复制到剪贴板的图片 clipboard) and wants you to see its content. Calls an OpenAI-compatible vision API (supports multiple provider profiles, configurable auth header, structured JSON output) or falls back to local ollama; also supports clipboard images, remote URLs, PDFs and multi-image analysis, then returns the description so the task can continue.
---

# Image Vision(图片识别)

当主模型不支持图片输入(Read 工具返回 `this model does not support image input`)或用户给出图片并要求查看内容时,用本 skill 把图片交给视觉 API 识别,再根据返回结果继续执行。

支持:**本地图片 / 剪贴板截图 / 远程 URL / PDF 文档 / 多图一次分析**;**多供应商配置档案**(OpenAI 兼容接口 + 本地 ollama);**结构化 JSON 输出**。

## 0. 统一入口(推荐)

本 skill 自带 `vision` 命令(shell wrapper,自动定位脚本目录,无需硬编码路径):

```bash
# 方式一:直接调用(脚本会按自身位置找到 analyzer)
vision <图片路径> [提示词]

# 方式二:通过 skill 目录调用(若 vision 不在 PATH)
bash "$SKILL_DIR/vision" <图片路径>
```

`SKILL_DIR` 即本 skill 所在目录,由运行环境的 skill 加载器提供;若未提供,可用 `~/.agents/skills/image-vision/` 或实际安装路径。

## 1. 确认图片来源

按优先级获取:

**A. opencode 数据库(粘贴/上传的图片)**:用户在 opencode 里粘贴或上传过图片(消息中显示 `ERROR: Cannot read image.png` 或 `图片内容` 缩略图),图片已存入 opencode SQLite 数据库(`part` 表),直接提取:

```bash
vision --get                      # 提取最新图片,输出路径
vision --get --all                # 列出最近图片
vision --get --index 1            # 提取次新图片
vision --get --db /path/to/opencode.db   # 指定数据库
```

提取成功后把输出路径传给识别命令。

**B. 剪贴板(推荐,支持复制/截图)**:用户刚复制过图片(微信/截图工具 Ctrl+C),直接:

```bash
vision --clipboard "识别提示词"
```

> 注意:Linux 可能未安装 `xclip`/`wl-paste`,剪贴板读取会失败并给出提示。此时请使用路径方式。

**C. 路径 / URL / PDF**:消息附带 `filePath`、用户给了路径、远程链接或 PDF 文档,直接传。

**D. 路径缺失/找不到**:模型只看到 `image.png` 之类引用但磁盘没有时:
1. 先运行脚本传该路径——脚本会自动在**截图目录、桌面、下载、临时目录、当前目录**(可用 `VISION_SEARCH_DIRS` 扩展)按文件名搜索;
2. 找不到会**自动回退读剪贴板**(若可用);
3. 两者都无则提示用户:请提供真实路径(或安装 xclip 后复制图片重试)。

## 2. 运行识别脚本

### 常见用法(`vision` 等价于 `python <skill目录>/analyze_image.py`)

| 场景 | 命令 |
|------|------|
| 单图识别 | `vision <图片路径> [提示词]` |
| 剪贴板图片 | `vision --clipboard [提示词]` |
| 远程 URL | `vision https://example.com/a.png [提示词]` |
| PDF 文档 | `vision report.pdf [提示词]`(提取首页,需 poppler-utils) |
| 多图一次分析 | `vision a.png b.jpg c.png --prompt "对比这几张图"` |
| 结构化输出 | `vision a.png --json` |
| 指定配置档案 | `vision a.png --profile dashscope` |
| 指定 PDF 页 | `vision a.pdf --pdf-page 3` |
| 跳过缓存 | `vision a.png --no-cache` |

第二个位置参数是提示词(可选),默认 `"Describe this image in detail."`。按任务需要传中文提示词,例如:`"识别这张图里的报错信息"`、`"提取图片中的表格数据"`、`"描述界面截图"`、`"对比两张图的差异"`。

### 参数速查

| 参数 | 说明 |
|------|------|
| `--prompt "..."` | 识别提示词(推荐,避免与旧位置参数混淆) |
| `--clipboard` | 从剪贴板读取图片 |
| `--json` | 输出 JSON(含 result / provider / model / images / elapsed) |
| `--profile NAME` | 切换 config.json 中的配置档案 |
| `--timeout 240` | 请求超时秒数(默认 240,ollama 首载建议 ≥240) |
| `--pdf-page N` | PDF 提取第 N 页(默认 1) |
| `--no-cache` | 跳过结果缓存 |
| `--images`(无) | 多图直接用多个位置参数,如 `a.png b.jpg` |

## 3. 配置

### config.json(本 skill 目录;路径可用环境变量 `VISION_CONFIG` 覆盖)

支持两种格式:

**旧平铺格式(兼容)**:

```json
{
  "url": "https://.../chat/completions",
  "key": "sk-...",
  "model": "qwen-vl-max"
}
```

**多档案格式(推荐,可同时配置多个供应商)**:

```json
{
  "default_profile": "sensenova",
  "profiles": {
    "sensenova": {
      "url": "https://token.sensenova.cn/v1",
      "key": "sk-...",
      "model": "sensenova-6.7-flash-lite"
    },
    "dashscope": {
      "url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
      "key": "sk-...",
      "model": "qwen-vl-max"
    },
    "siliconflow": {
      "url": "https://api.siliconflow.cn/v1/chat/completions",
      "key": "sk-...",
      "model": "Qwen/Qwen2.5-VL-72B-Instruct"
    },
    "openai": {
      "url": "https://api.openai.com/v1/chat/completions",
      "key": "sk-...",
      "model": "gpt-4o-mini"
    },
    "ollama-local": {
      "ollama": true,
      "url": "http://127.0.0.1:11434",
      "ollama_model": "qwen3.5:4b-q4_K_M"
    }
  }
}
```

档案可选字段:`url` / `key` / `model` / `auth_header`(默认 `Authorization`)/ `auth_prefix`(默认 `Bearer `)/ `max_tokens` / `temperature`;ollama 档案用 `"ollama": true` + `ollama_model`。

> 特殊鉴权:某些服务用 `api-key` 等请求头而非 Bearer,配置 `"auth_header": "api-key", "auth_prefix": ""` 即可。

### 环境变量(优先级高于 config.json)

| 变量 | 说明 |
|------|------|
| `VISION_API_URL` / `VISION_API_KEY` / `VISION_MODEL` | 覆盖当前档案 |
| `VISION_PROFILE` | 默认档案名 |
| `VISION_OLLAMA_MODEL` | ollama 模型名 |
| `VISION_MAX_TOKENS` / `VISION_TEMPERATURE` | 请求参数 |
| `VISION_CONFIG` | 配置文件路径(默认 skill 目录 config.json) |
| `VISION_CACHE` | 缓存文件路径(默认 skill 目录 cache.json) |
| `VISION_SEARCH_DIRS` | 冒号分隔的搜索目录(找不到路径时按文件名搜) |

## 4. 依据识别结果继续执行

把脚本返回的图片描述作为输入,继续完成用户原任务(分析报错、写测试点、改代码等)。

需要结构化结果(给后续脚本/程序用)时加 `--json`,输出示例:

```json
{
  "result": "图片描述...",
  "cached": false,
  "provider": "sensenova",
  "kind": "openai",
  "model": "sensenova-6.7-flash-lite",
  "images": ["/tmp/opencode/opencode_image.png"],
  "prompt": "识别这张图里的报错信息",
  "elapsed": 3.2
}
```

## 备选:本地 ollama(url 为空 / profile 标记 ollama 时自动回退)

- 脚本在未配置远程 API 时自动尝试本地 `http://127.0.0.1:11434` 的视觉模型。
- 模型默认 `qwen3.5:4b-q4_K_M`,可用 `VISION_OLLAMA_MODEL` 或档案 `ollama_model` 覆盖。
- 首次调用模型需加载,可能较慢(几十秒~分钟),超时用大一点(240s+)。
遇到问题先看第 6 节;仍然卡住,把报错原文发给作者即可。
📦 一键下载:完整代码已开源到 GitHub(含 image-vision 与 notion-blog 两个 Skill)

读者评论

评论会同步写入该文在 Notion 中的页面底部(与正文同页,便于管理)。

0/1500

暂无评论,欢迎抢沙发。