Skip to content

Commit 14caaaf

Browse files
committed
feat(1.5.0): P1-2 expand NATIVE_VISION (pixtral, llama, doubao, etc.), P1-5 README sync, P2-1 type assertions, P2-7 unit tests (68 pass), P2-8 install.js writes opencode.json, P2-9 uninstall cleans tmp dir, P2-10 empty path filter
1 parent 0b695b0 commit 14caaaf

5 files changed

Lines changed: 234 additions & 14 deletions

File tree

README.md

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,25 @@ npx @jochenyang/opencode-vision --uninstall
4747
用户粘贴图片 + "这是什么?"
4848
4949
vision-helper 插件 (experimental.chat.messages.transform)
50-
├─ 解 base64 → 保存到临时目录
51-
├─ 用简短占位替换原始图片部分(消除不支持的模型的 ERROR 噪音)
52-
└─ 路径提示注入到用户文本前
50+
├─ 检查当前模型是否原生多模态 → 是则跳过(直接看图)
51+
├─ 检查是否在压缩/总结阶段 → 是则跳过
52+
├─ 解 base64 → 计算 MD5 → 复用/分配 image{N} 序号
53+
├─ 保存到 tmp/opencode-vision/image{N}/{hash}.{ext}
54+
├─ 用简短占位替换原始图片 part(消除 ERROR 噪音)
55+
├─ 注入路径提示(vision tool 引导)
56+
└─ 清理上次 transform 注入的 hint(防累积)
5357
5458
模型看到路径提示 → 自动调用 vision 工具
5559
56-
vision 工具调用视觉 API 返回图片描述
60+
vision 工具:
61+
├─ 路径沙箱校验(必须在 TMP_DIR 下,必须是图片扩展名)
62+
├─ base64 编码 → 调视觉 API
63+
└─ 返回描述
5764
```
5865

5966
- **单图** → 模型调用 `vision(path)` 读取单张图片
6067
- **多图** → 模型调用 `vision(paths=[...])` 一次 API 调用处理全部图片
68+
- **原生多模态模型**(如 GPT-4o、MiniMax-M3、Qwen3.5+、Kimi K2.5+)→ 自动跳过插件,原生看图
6169

6270
## 前置要求
6371

@@ -67,12 +75,15 @@ vision 工具调用视觉 API 返回图片描述
6775

6876
## 环境变量
6977

70-
| 变量 | 说明 | 示例值 |
78+
| 变量 | 说明 | 默认值 / 示例 |
7179
| ------------------ | -------------------------------------- | ------------------------------------------------------------- |
7280
| `VISION_API_KEY` | 视觉 API 的密钥 | `sk-your-api-key` |
7381
| `VISION_API_URL` | 视觉 API 的基础地址 | `https://your-api-endpoint/v1` |
7482
| `VISION_MODEL` | 视觉模型名称<br>(MiniMax 无需设置) | `your-vision-model` |
7583
| `VISION_API_TYPE` | 可选,强制指定 API 类型<br>`openai` / `minimax` | `minimax` |
84+
| `VISION_MAX_TOKENS` | 可选,视觉 API 返回的最大 token 数 | `4096` |
85+
| `VISION_FETCH_TIMEOUT_MS` | 可选,视觉 API fetch 超时毫秒数 | `60000` |
86+
| `VISION_MAX_IMAGES` | 可选,LRU 保留的最大图片数 | `200` |
7687

7788
> `VISION_API_URL`:OpenAI 兼容接口会自动补全 `/chat/completions`;MiniMax 会自动使用 `/v1/coding_plan/vlm` 端点。
7889
>
@@ -194,10 +205,12 @@ opencode-vision/
194205

195206
## 注意事项
196207

197-
- 图片保存到系统临时目录 `os.tmpdir()/opencode-vision/`,重启系统后自动清理
198-
- 临时文件以 `pasted-{timestamp}-{random}.{ext}` 命名
199-
- 同一会话中多次粘贴同一张图会产生多个临时文件
200-
- 视觉 API 调用使用 `max_tokens: 4096`,多图场景下足够返回详细描述
208+
- 图片保存到系统临时目录 `os.tmpdir()/opencode-vision/image{N}/{hash}.{ext}`(N 为全局序号)
209+
- 同一会话中**同一张图**通过 MD5 内容哈希去重,**不会**重复写盘
210+
- **LRU 清理**:默认保留最近 200 张图片(可通过 `VISION_MAX_IMAGES` 环境变量调整)
211+
- 视觉 API 调用使用 `max_tokens: 4096`(可通过 `VISION_MAX_TOKENS` 调整)
212+
- 视觉 API fetch 默认 60s 超时(可通过 `VISION_FETCH_TIMEOUT_MS` 调整)
213+
- **多模态模型自动原生识图**:GPT-4o+/Claude 3+/Gemini/MiniMax-M3/Qwen3.5+/Kimi K2.5+ 等模型无需配置视觉 API,插件会自动跳过
201214

202215
## 自定义视觉 API
203216

bin/install.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ async function doInstall() {
122122
log("未检测到 OpenCode — 请先安装 https://github.com/opencode-ai/opencode", false)
123123
}
124124

125+
// P2-8: offer to write env vars into opencode.json so users don't have to restart
126+
if (missing > 0) {
127+
await offerWriteOpencodeJson()
128+
}
129+
125130
title("安装完成")
126131
console.log(" ✅ 文件已就位,重启 OpenCode 后即可使用。")
127132
if (missing > 0) {
@@ -132,6 +137,79 @@ async function doInstall() {
132137
console.log(' "[图片] 这是什么?"')
133138
}
134139

140+
/**
141+
* P2-8: 交互式询问是否将环境变量写入 ~/.config/opencode/opencode.json 的 env 字段。
142+
* 这样用户不需要重启终端或系统就能让变量生效。
143+
*/
144+
async function offerWriteOpencodeJson() {
145+
const configPath = path.join(DST, "opencode.json")
146+
title("opencode.json 写入选项")
147+
console.log(" 检测到环境变量未完整配置。")
148+
console.log(" 可以直接将变量写入 ~/.config/opencode/opencode.json(无需重启终端)。")
149+
console.log()
150+
console.log(" \x1b[36m写入内容预览:\x1b[0m")
151+
const preview = {
152+
env: {
153+
VISION_API_KEY: "<your-key-here>",
154+
VISION_API_URL: "<your-api-url-here>",
155+
...(process.env["VISION_API_TYPE"] ? { VISION_API_TYPE: process.env["VISION_API_TYPE"] } : {}),
156+
},
157+
}
158+
console.log(" " + JSON.stringify(preview, null, 2).split("\n").join("\n "))
159+
console.log()
160+
161+
const readline = require("readline")
162+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
163+
const answer = await new Promise((resolve) =>
164+
rl.question(" 写入 opencode.json? [y/N] ", (ans) => {
165+
rl.close()
166+
resolve(ans)
167+
}),
168+
)
169+
170+
if (!/^y(es)?$/i.test(answer.trim())) {
171+
console.log(" 跳过写入,请手动设置环境变量。")
172+
return
173+
}
174+
175+
// Read or init config
176+
let config = {}
177+
if (fs.existsSync(configPath)) {
178+
try {
179+
config = JSON.parse(fs.readFileSync(configPath, "utf8"))
180+
} catch (err) {
181+
log(`无法读取 ${configPath}: ${err.message},请手动备份后重试`, false)
182+
return
183+
}
184+
}
185+
config.env = config.env || {}
186+
187+
// For each missing env var, prompt for a value
188+
const readline2 = require("readline")
189+
const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout })
190+
for (const v of ENV_VARS) {
191+
if (process.env[v.name]) continue
192+
const val = await new Promise((resolve) =>
193+
rl2.question(` ${v.name}: `, (ans) => resolve(ans.trim())),
194+
)
195+
if (val) {
196+
config.env[v.name] = val
197+
log(`已记录 ${v.name}`)
198+
}
199+
}
200+
rl2.close()
201+
202+
// Write back with a .bak backup
203+
if (fs.existsSync(configPath)) {
204+
fs.copyFileSync(configPath, configPath + ".bak")
205+
}
206+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
207+
log(`已写入 ${path.relative(os.homedir(), configPath)}`)
208+
if (fs.existsSync(configPath + ".bak")) {
209+
log(`原文件已备份到 ${path.basename(configPath)}.bak`)
210+
}
211+
}
212+
135213
async function doUninstall() {
136214
title("opencode-vision 卸载")
137215

@@ -153,6 +231,17 @@ async function doUninstall() {
153231
}
154232
}
155233

234+
// P2-9: clean up the temp directory containing cached images (user privacy)
235+
const tmpDir = path.join(os.tmpdir(), "opencode-vision")
236+
if (fs.existsSync(tmpDir)) {
237+
try {
238+
fs.rmSync(tmpDir, { recursive: true, force: true })
239+
log(`已清理临时目录 ${path.relative(os.tmpdir(), tmpDir)}/* (含缓存图片)`)
240+
} catch (err) {
241+
log(`清理临时目录失败: ${err.message}`, false)
242+
}
243+
}
244+
156245
title("卸载完成")
157246
if (removed > 0) {
158247
console.log(" 已删除 opencode-vision 相关文件。")

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@jochenyang/opencode-vision",
3-
"version": "1.4.0",
3+
"version": "1.5.0",
44
"description": "Vision plugin + tool for OpenCode — automatically handles pasted images for non-vision models",
55
"keywords": [
66
"opencode",

plugins/vision-helper.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,30 @@ function touchLRU(seqDir: string) {
3333
// Regex matching models with native multimodal (image) support.
3434
// Matched models → skip the transform, let OpenCode's native pipeline handle the image.
3535
// Non-matched models → fall through to the vision tool.
36-
const NATIVE_VISION = /gpt-|o[0-9]|claude-|gemini-|qwen3\.(5|6)|qwen-vl|qwen2-5-vl|qwen3-vl|qwen-omni|qvq-max|kimi-k2\.(5|6)|minimax-m3|minimax-vl|glm-[0-9.]+v|mimo-v2-omni|mimo-v2\.5$|yi-vl|deepseek-vl2/i
36+
//
37+
// Coverage:
38+
// Foreign : GPT-4o+, o1/o3/o4+, Claude 3/4/5, Gemini, Pixtral, Llama 3.2 Vision, Nova Vision
39+
// Alibaba : Qwen VL/Omni series, Qwen 3.5/3.6, QVQ-Max
40+
// Kimi : kimi-k2.5, kimi-k2.6
41+
// MiniMax : MiniMax-M3, MiniMax-VL-*
42+
// GLM : glm-*v (with "v" suffix = vision variant)
43+
// Xiaomi : mimo-v2-omni, mimo-v2.5
44+
// Yi : yi-vl-*
45+
// DeepSeek : deepseek-vl2 (SiliconFlow hosted)
46+
// Doubao : doubao-*-vision
47+
// InternVL : internvl*
48+
// Seed : seed-*-vision
49+
//
50+
// Note on Qwen 3.5/3.6: NOT followed by `-max`/`-max-preview` (which are text-only).
51+
// Match: qwen3.5-plus, qwen3.6-flash, qwen3.6-plus, qwen3.5-flash
52+
// Skip : qwen3.6-max-preview (text), qwen3.7-max (text), qwen3-max (text)
53+
// Foreign: GPT/o[0-9]/Claude/Gemini/Pixtral/Llama 3.2 Vision/Nova Vision
54+
// Qwen VL family: matches qwen-vl-*, qwen2-vl-*, qwen2.5-vl-*, qwen3-vl-*, qwen-omni-*, qvq-max
55+
// The "qwen-vl" branch handles the no-version-number case; the numbered branch
56+
// matches qwen2/2.5/3 with optional separator variants (-/_/.)
57+
// Qwen 3.5/3.6 multimodal series: qwen3.5-plus, qwen3.5-flash, qwen3.6-plus, qwen3.6-flash
58+
// Negative lookahead (?!-max) excludes text-only qwen3.6-max-preview / qwen3.7-max / qwen3-max
59+
const NATIVE_VISION = /gpt-|o[0-9]|claude-|gemini-|pixtral|llama-3\.2.*vision|nova-.*vision|qwen3\.[56](?!-max)|qwen(?:-vl|2[._-]?5?[._-]?vl)|qwen2-vl|qwen3-vl|qwen-omni|qvq-max|kimi-k2\.(5|6)|minimax-m3|minimax-vl|glm-[0-9.]+v|mimo-v2-omni|mimo-v2\.5$|yi-vl|deepseek-vl2|doubao-.*vision|seed-.*vision|internvl/i
3760

3861
/**
3962
* Hook runs just before messages are sent to the model. Detects image attachments in
@@ -131,16 +154,16 @@ export default (async () => {
131154
msg.parts.splice(index, 1, {
132155
type: "text",
133156
text: `[vision: image${seq}/${name}]`,
134-
} as never)
157+
} as { type: "text"; text: string })
135158
}
136159

137160
// Build path hint(s) for the model to use the vision tool
138161
const hints = saved.length === 1
139162
? `[Image #${saved[0].seq} auto-saved to ${path.join(TMP_DIR, `image${saved[0].seq}`, saved[0].name)} — use the vision tool to read it]`
140163
: `[Images auto-saved to:\n${saved.map((s) => ` ${path.join(TMP_DIR, `image${s.seq}`, s.name)}`).join("\n")}\n— use the vision tool with paths=[...] to read them all at once]`
141164

142-
;(msg.parts as unknown as Record<string, unknown>[]).push({
143-
type: "text" as const,
165+
msg.parts.push({
166+
type: "text",
144167
text: hints,
145168
})
146169
}

tests/native-vision.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, test } from "bun:test"
2+
3+
// Mirror the NATIVE_VISION regex from the plugin so we can test the matching logic
4+
// without importing the plugin module (which has side effects on load).
5+
const NATIVE_VISION = /gpt-|o[0-9]|claude-|gemini-|pixtral|llama-3\.2.*vision|nova-.*vision|qwen3\.[56](?!-max)|qwen(?:-vl|2[._-]?5?[._-]?vl)|qwen2-vl|qwen3-vl|qwen-omni|qvq-max|kimi-k2\.(5|6)|minimax-m3|minimax-vl|glm-[0-9.]+v|mimo-v2-omni|mimo-v2\.5$|yi-vl|deepseek-vl2|doubao-.*vision|seed-.*vision|internvl/i
6+
7+
describe("NATIVE_VISION regex", () => {
8+
// Should match (native multimodal)
9+
const yesCases: Array<[string, string]> = [
10+
["gpt-5.4", "OpenAI latest"],
11+
["gpt-4o", "OpenAI 4o"],
12+
["gpt-4o-mini", "OpenAI 4o mini"],
13+
["gpt-5-codex", "OpenAI codex"],
14+
["o4-mini", "OpenAI o-series"],
15+
["o1", "OpenAI o1"],
16+
["claude-sonnet-4-6", "Anthropic sonnet"],
17+
["claude-3-5-sonnet", "Anthropic 3.5"],
18+
["claude-opus-4-6", "Anthropic opus"],
19+
["gemini-2.5-pro", "Google gemini"],
20+
["gemini-3-pro", "Google gemini 3"],
21+
["pixtral-12b", "Mistral pixtral"],
22+
["llama-3.2-90b-vision", "Meta llama 3.2 vision"],
23+
["llama-3.2-11b-vision", "Meta llama 3.2 vision"],
24+
["nova-pro-vision", "AWS nova vision"],
25+
["qwen3.5-plus", "Qwen 3.5 plus"],
26+
["qwen3.6-flash", "Qwen 3.6 flash"],
27+
["qwen-vl-max", "Qwen VL max"],
28+
["qwen2-5-vl-72b-instruct", "Qwen 2.5 VL"],
29+
["qwen2-vl-7b", "Qwen 2 VL"],
30+
["qwen3-vl-30b", "Qwen 3 VL"],
31+
["qwen-omni-turbo", "Qwen omni"],
32+
["qvq-max", "Qwen QVQ"],
33+
["kimi-k2.5", "Kimi 2.5"],
34+
["kimi-k2.6", "Kimi 2.6"],
35+
["moonshotai/Kimi-K2.5", "Kimi prefixed"],
36+
["MiniMax-M3", "MiniMax M3 (case-insensitive)"],
37+
["minimax-m3", "MiniMax M3 lower"],
38+
["minimax-vl-01", "MiniMax VL"],
39+
["glm-4.5v", "GLM 4.5 vision"],
40+
["glm-4.6v", "GLM 4.6 vision"],
41+
["glm-5v-turbo", "GLM 5 vision turbo"],
42+
["mimo-v2-omni", "Xiaomi MiMo omni"],
43+
["mimo-v2.5", "Xiaomi MiMo 2.5"],
44+
["yi-vl-6b", "Yi VL"],
45+
["deepseek-vl2", "DeepSeek VL2"],
46+
["Qwen/Qwen2.5-VL-72B-Instruct", "Qwen with org prefix"],
47+
["doubao-1-5-vision-pro", "Doubao vision"],
48+
["seed-1-6-vision", "Seed vision"],
49+
["internvl2.5", "InternVL"],
50+
]
51+
52+
for (const [id, desc] of yesCases) {
53+
test(`matches native vision: ${id} (${desc})`, () => {
54+
expect(NATIVE_VISION.test(id.toLowerCase())).toBe(true)
55+
})
56+
}
57+
58+
// Should NOT match (text-only, fall back to vision tool)
59+
const noCases: Array<[string, string]> = [
60+
["deepseek-v4-flash", "DeepSeek V4 Flash"],
61+
["deepseek-v4-pro", "DeepSeek V4 Pro"],
62+
["deepseek-r1", "DeepSeek R1"],
63+
["deepseek-chat", "DeepSeek chat"],
64+
["MiniMax-M2.7", "MiniMax M2.7"],
65+
["MiniMax-M2.5", "MiniMax M2.5"],
66+
["MiniMax-M2", "MiniMax M2"],
67+
["qwen3-max", "Qwen 3 max"],
68+
["qwen3.7-max", "Qwen 3.7 max"],
69+
["qwen3.6-max-preview", "Qwen 3.6 max preview"],
70+
["qwen-plus", "Qwen plus"],
71+
["qwen-flash", "Qwen flash"],
72+
["qwen-turbo", "Qwen turbo"],
73+
["qwen3-32b", "Qwen 3 32B"],
74+
["qwen3-235b-a22b", "Qwen 3 235B"],
75+
["qwen3-coder-plus", "Qwen coder"],
76+
["glm-4.5", "GLM 4.5"],
77+
["glm-4.6", "GLM 4.6"],
78+
["glm-4.7", "GLM 4.7"],
79+
["glm-5", "GLM 5"],
80+
["glm-5.1", "GLM 5.1"],
81+
["mimo-v2-flash", "MiMo 2 flash"],
82+
["mimo-v2-pro", "MiMo 2 pro"],
83+
["mimo-v2.5-pro", "MiMo 2.5 pro"],
84+
["kimi-k2-thinking", "Kimi thinking"],
85+
["kimi-k2-turbo", "Kimi turbo"],
86+
["step-1-32k", "Step 1"],
87+
["step-3.5-flash", "Step 3.5"],
88+
]
89+
90+
for (const [id, desc] of noCases) {
91+
test(`does NOT match (text-only): ${id} (${desc})`, () => {
92+
expect(NATIVE_VISION.test(id.toLowerCase())).toBe(false)
93+
})
94+
}
95+
})

0 commit comments

Comments
 (0)