opencode 图片识别 Skill(image-vision)安装与使用教程
opencode 图片识别 Skill(image-vision)完整教程
0. 先搞懂:它解决什么问题
很多 opencode 用户会遇到这个场景——发一张截图给 AI:
用户: 帮我看看这个报错
ERROR: Cannot read "image.png" (this model does not support image input)原因是当前模型(如某些轻量模型)不支持图片输入。本 Skill 的解法:把图片提取出来 → 发给视觉 API(如通义千问 VL、GPT-4o)→ 拿到文字描述 → 主模型基于描述继续回答。
适合人群:使用无视觉能力模型的 opencode 用户;或任何想让 AI 看懂图片/PDF/截图的场景。
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 --help2. 配置视觉 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。
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 秒看懂)
识别结果按图片哈希缓存(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. 完整代码(复制即用)
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+)。
读者评论
评论会同步写入该文在 Notion 中的页面底部(与正文同页,便于管理)。
暂无评论,欢迎抢沙发。