opencode-minimax-easy-vision 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -69
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +288 -184
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
# MiniMax Easy Vision
|
|
1
|
+
# Opencode MiniMax Easy Vision
|
|
2
2
|
|
|
3
|
-
MiniMax Easy Vision is a plugin for [OpenCode](https://opencode.ai) that enables **vision support**
|
|
3
|
+
MiniMax Easy Vision is a plugin for [OpenCode](https://opencode.ai) that enables **vision support** for models that lack native image attachment support.
|
|
4
|
+
|
|
5
|
+
Originally built for [MiniMax](https://www.minimax.io/) models, it can be configured to work with any model that requires MCP-based image handling.
|
|
6
|
+
|
|
7
|
+
It restores the "paste and ask" workflow by automatically saving image assets and routing them through the [MiniMax Coding Plan MCP](https://github.com/MiniMax-AI/MiniMax-Coding-Plan-MCP)
|
|
4
8
|
|
|
5
9
|
## Demo
|
|
6
10
|
|
|
@@ -12,40 +16,118 @@ https://github.com/user-attachments/assets/826f90ea-913f-427e-ace8-0b711302c497
|
|
|
12
16
|
|
|
13
17
|
## The Problem
|
|
14
18
|
|
|
15
|
-
When using MiniMax models (
|
|
19
|
+
When using MiniMax models (like MiniMax M2.1) in OpenCode, native image attachments aren't supported.
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
These models expect the MiniMax Coding Plan MCP's `understand_image` tool, which requires an explicit file path. This breaks the normal flow:
|
|
18
22
|
|
|
19
|
-
* **Ignored images**:
|
|
20
|
-
* **Manual steps**:
|
|
21
|
-
* **Broken flow**: The
|
|
23
|
+
* **Ignored images**: Pasted images are simply ignored by the model.
|
|
24
|
+
* **Manual steps**: You have to save screenshots manually, find the path, and reference it in your prompt.
|
|
25
|
+
* **Broken flow**: The "paste and ask" experience available with Claude or GPT models is lost.
|
|
22
26
|
|
|
23
27
|
## What This Plugin Does
|
|
24
28
|
|
|
25
|
-
This plugin
|
|
29
|
+
This plugin automates the vision pipeline so you don't have to think about it.
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
**How it works:**
|
|
28
32
|
|
|
29
|
-
1. Detects when a
|
|
30
|
-
2. Intercepts images pasted into the chat
|
|
31
|
-
3. Saves them to a temporary local directory
|
|
32
|
-
4. Injects the
|
|
33
|
+
1. **Detects** when a configured model is active.
|
|
34
|
+
2. **Intercepts** images pasted into the chat.
|
|
35
|
+
3. **Saves** them to a temporary local directory.
|
|
36
|
+
4. **Injects** the necessary context for the model to invoke the `understand_image` tool with the correct path.
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
**Result:** You just paste the image and ask your question just like how you do with Claude or GPT models. The plugin handles the rest.
|
|
35
39
|
|
|
36
40
|
## Supported Models
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
By default, the plugin activates for MiniMax models:
|
|
39
43
|
|
|
40
44
|
* **Provider ID** containing `minimax`
|
|
41
45
|
* **Model ID** containing `minimax` or `abab`
|
|
42
46
|
|
|
43
|
-
Examples
|
|
44
|
-
|
|
47
|
+
**Examples:**
|
|
45
48
|
* `minimax/minimax-m2.1`
|
|
46
49
|
* `minimax/abab6.5s-chat`
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
### Custom Model Configuration
|
|
52
|
+
|
|
53
|
+
You can enable this for other models by creating a config file.
|
|
54
|
+
|
|
55
|
+
#### Locations (Priority Order)
|
|
56
|
+
|
|
57
|
+
1. **Project level**: `.opencode/opencode-minimax-easy-vision.json`
|
|
58
|
+
2. **User level**: `~/.config/opencode/opencode-minimax-easy-vision.json`
|
|
59
|
+
|
|
60
|
+
#### Config Format
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"models": ["minimax/*", "opencode/*", "*/glm-4.7-free"]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
#### Pattern Syntax
|
|
69
|
+
|
|
70
|
+
| Pattern | Matches |
|
|
71
|
+
| ---------------- | --------------------------------------- |
|
|
72
|
+
| `*` | Match ALL models |
|
|
73
|
+
| `minimax/*` | All models from the `minimax` provider |
|
|
74
|
+
| `*/glm-4.7-free` | Specific model from any provider |
|
|
75
|
+
| `opencode/*` | All models from the `opencode` provider |
|
|
76
|
+
| `*/abab*` | Any model containing `abab` |
|
|
77
|
+
|
|
78
|
+
#### Wildcard Rules
|
|
79
|
+
|
|
80
|
+
* `*suffix` matches values ending with `suffix`
|
|
81
|
+
* `prefix*` matches values starting with `prefix`
|
|
82
|
+
* `*` matches everything
|
|
83
|
+
* `*text*` matches values containing `text`
|
|
84
|
+
|
|
85
|
+
If the config is missing or empty, it defaults to MiniMax-only behavior.
|
|
86
|
+
|
|
87
|
+
#### Configuration Examples
|
|
88
|
+
|
|
89
|
+
**Enable for all models:**
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"models": ["*"]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Specific providers:**
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"models": ["minimax/*", "opencode/*", "google/*"]
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Mix of providers and models:**
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"models": ["minimax/*", "opencode/gpt-5-nano", "*/claude-3-7-sonnet*"]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Custom Image Analysis Tool
|
|
114
|
+
|
|
115
|
+
By default, the plugin uses `mcp_minimax_understand_image` from the MiniMax Coding Plan MCP. You can configure a different MCP tool for image analysis:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"models": ["*"],
|
|
120
|
+
"imageAnalysisTool": "mcp_openrouter_analyze_image"
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
This allows you to use other MCP servers that provide image analysis capabilities, such as:
|
|
125
|
+
|
|
126
|
+
* [openrouter-image-mcp](https://github.com/JonathanJude/openrouter-image-mcp) - Uses OpenRouter with GPT-4V, Claude, Gemini
|
|
127
|
+
* [mcp-image-recognition](https://github.com/mario-andreschak/mcp-image-recognition) - Uses Anthropic/OpenAI Vision APIs
|
|
128
|
+
* [Peekaboo](https://github.com/steipete/Peekaboo) - macOS screenshot + AI analysis
|
|
129
|
+
|
|
130
|
+
The plugin will instruct the model to use the configured tool. The tool should accept an image file path as input.
|
|
49
131
|
|
|
50
132
|
## Supported Image Formats
|
|
51
133
|
|
|
@@ -53,13 +135,13 @@ Non-MiniMax models are not affected. Their native vision support continues to wo
|
|
|
53
135
|
* JPEG
|
|
54
136
|
* WebP
|
|
55
137
|
|
|
56
|
-
|
|
138
|
+
*(Limited by the [MiniMax Coding Plan MCP](https://github.com/MiniMax-AI/MiniMax-Coding-Plan-MCP) `understand_image` tool.)*
|
|
57
139
|
|
|
58
140
|
## Installation
|
|
59
141
|
|
|
60
142
|
### Via npm
|
|
61
143
|
|
|
62
|
-
|
|
144
|
+
Just add the plugin to the `plugin` array in your `opencode.json` file:
|
|
63
145
|
|
|
64
146
|
```json
|
|
65
147
|
{
|
|
@@ -68,23 +150,18 @@ Add the plugin to the `plugin` array in your `opencode.json` file:
|
|
|
68
150
|
}
|
|
69
151
|
```
|
|
70
152
|
|
|
71
|
-
### From
|
|
153
|
+
### From Local Source
|
|
72
154
|
|
|
73
|
-
1. Clone
|
|
155
|
+
1. Clone the repository.
|
|
74
156
|
2. Build the plugin:
|
|
75
|
-
|
|
76
157
|
```bash
|
|
77
|
-
npm install
|
|
78
|
-
npm run build
|
|
158
|
+
npm install && npm run build
|
|
79
159
|
```
|
|
80
|
-
3. Copy the built
|
|
81
|
-
|
|
82
|
-
* Project-level: `.opencode/plugin/minimax-easy-vision.js`
|
|
83
|
-
* Global: `~/.config/opencode/plugin/minimax-easy-vision.js`
|
|
160
|
+
3. Copy the built `dist/index.js` into your OpenCode plugin directory.
|
|
84
161
|
|
|
85
162
|
## Prerequisites
|
|
86
163
|
|
|
87
|
-
The MiniMax Coding Plan MCP server must be configured in `opencode.json`:
|
|
164
|
+
The MiniMax Coding Plan MCP server must be configured in your `opencode.json`:
|
|
88
165
|
|
|
89
166
|
```json
|
|
90
167
|
{
|
|
@@ -101,42 +178,20 @@ The MiniMax Coding Plan MCP server must be configured in `opencode.json`:
|
|
|
101
178
|
}
|
|
102
179
|
```
|
|
103
180
|
|
|
104
|
-
For full setup details, refer to the MiniMax Coding Plan MCP and MiniMax API documentation.
|
|
105
|
-
|
|
106
181
|
## Usage
|
|
107
182
|
|
|
108
|
-
1.
|
|
109
|
-
2. Paste an image
|
|
110
|
-
3. Ask a question about
|
|
111
|
-
|
|
112
|
-
What happens internally:
|
|
113
|
-
|
|
114
|
-
* The image is saved to `{tmpdir}/opencode-minimax-vision/<uuid>.<ext>`
|
|
115
|
-
* Instructions are injected for the model to use the `understand_image` MCP tool
|
|
116
|
-
* The model performs vision analysis and responds
|
|
117
|
-
|
|
118
|
-
### Example interaction
|
|
119
|
-
|
|
120
|
-
```text
|
|
121
|
-
You: [pasted screenshot] What does this error message say?
|
|
122
|
-
|
|
123
|
-
# Automatically injected:
|
|
124
|
-
# [SYSTEM: Image Attachment Detected]
|
|
125
|
-
# 1 image has been saved to: /tmp/opencode-minimax-vision/abc123.png
|
|
126
|
-
# To analyze this image, use the understand_image MCP tool...
|
|
127
|
-
|
|
128
|
-
Model: I'll analyze the screenshot using the understand_image tool.
|
|
129
|
-
[Calls mcp_minimax_understand_image with the saved path]
|
|
130
|
-
Model: The error message indicates a "TypeError: Cannot read property 'foo' of undefined"...
|
|
131
|
-
```
|
|
183
|
+
1. Select a supported model in OpenCode.
|
|
184
|
+
2. Paste an image (`Cmd+V` / `Ctrl+V`).
|
|
185
|
+
3. Ask a question about it, just like how you do for other models with native vision support.
|
|
132
186
|
|
|
133
|
-
|
|
187
|
+
### Example Interaction
|
|
134
188
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
189
|
+
> **You**: [pasted screenshot] Why is this failing?
|
|
190
|
+
>
|
|
191
|
+
> **Model**: I'll check the image using the `understand_image` tool.
|
|
192
|
+
> `[Calls mcp_minimax_understand_image path="/tmp/xyz.png"]`
|
|
193
|
+
>
|
|
194
|
+
> **Model**: The error suggests a syntax error on line 12.
|
|
140
195
|
|
|
141
196
|
## Development
|
|
142
197
|
|
|
@@ -145,16 +200,16 @@ npm install
|
|
|
145
200
|
npm run build
|
|
146
201
|
```
|
|
147
202
|
|
|
148
|
-
The built plugin will be available at `dist/index.js
|
|
203
|
+
The built plugin will be available at `dist/index.js`
|
|
149
204
|
|
|
150
205
|
## License
|
|
151
206
|
|
|
152
|
-
GPL-3.0. See
|
|
207
|
+
GPL-3.0. See [LICENSE.md](./LICENSE.md)
|
|
153
208
|
|
|
154
209
|
## References
|
|
155
210
|
|
|
156
|
-
* [
|
|
157
|
-
* [
|
|
158
|
-
* [
|
|
159
|
-
* [
|
|
160
|
-
* [
|
|
211
|
+
* [OpenCode Official Website](https://opencode.ai)
|
|
212
|
+
* [OpenCode Plugins Documentation](https://opencode.ai/docs/plugins/)
|
|
213
|
+
* [MiniMax Official Website](https://www.minimax.io/)
|
|
214
|
+
* [MiniMax Coding Plan MCP Repository](https://github.com/MiniMax-AI/MiniMax-Coding-Plan-MCP)
|
|
215
|
+
* [MiniMax API Documentation](https://platform.minimax.io/docs/guides/coding-plan-mcp-guide)
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAqdlD,eAAO,MAAM,uBAAuB,EAAE,MA+DrC,CAAC;AAEF,eAAe,uBAAuB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,236 +1,356 @@
|
|
|
1
|
-
import { tmpdir } from "node:os";
|
|
1
|
+
import { tmpdir, homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
|
|
6
|
-
* Plugin name for logging
|
|
7
|
-
*/
|
|
6
|
+
// Constants
|
|
8
7
|
const PLUGIN_NAME = "minimax-easy-vision";
|
|
9
|
-
|
|
10
|
-
* Temp directory name for saved images
|
|
11
|
-
*/
|
|
8
|
+
const CONFIG_FILENAME = "opencode-minimax-easy-vision.json";
|
|
12
9
|
const TEMP_DIR_NAME = "opencode-minimax-vision";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
const MAX_TOOL_NAME_LENGTH = 256;
|
|
11
|
+
const DEFAULT_MODEL_PATTERNS = ["minimax/*", "*/abab*"];
|
|
12
|
+
const DEFAULT_IMAGE_ANALYSIS_TOOL = "mcp_minimax_understand_image";
|
|
16
13
|
const SUPPORTED_MIME_TYPES = new Set([
|
|
17
14
|
"image/png",
|
|
18
15
|
"image/jpeg",
|
|
19
16
|
"image/jpg",
|
|
20
17
|
"image/webp",
|
|
21
18
|
]);
|
|
22
|
-
/**
|
|
23
|
-
* Map MIME type to file extension
|
|
24
|
-
*/
|
|
25
19
|
const MIME_TO_EXTENSION = {
|
|
26
20
|
"image/png": "png",
|
|
27
21
|
"image/jpeg": "jpg",
|
|
28
22
|
"image/jpg": "jpg",
|
|
29
23
|
"image/webp": "webp",
|
|
30
24
|
};
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
function
|
|
25
|
+
// Plugin State
|
|
26
|
+
let pluginConfig = {};
|
|
27
|
+
// Config: Path Resolution
|
|
28
|
+
function getUserConfigPath() {
|
|
29
|
+
return join(homedir(), ".config", "opencode", CONFIG_FILENAME);
|
|
30
|
+
}
|
|
31
|
+
function getProjectConfigPath(directory) {
|
|
32
|
+
return join(directory, ".opencode", CONFIG_FILENAME);
|
|
33
|
+
}
|
|
34
|
+
// Config: File Parsing
|
|
35
|
+
function parseModelsArray(value) {
|
|
36
|
+
if (!Array.isArray(value))
|
|
37
|
+
return undefined;
|
|
38
|
+
const models = value.filter((m) => typeof m === "string");
|
|
39
|
+
return models.length > 0 ? models : undefined;
|
|
40
|
+
}
|
|
41
|
+
function parseImageAnalysisTool(value) {
|
|
42
|
+
if (typeof value !== "string")
|
|
43
|
+
return undefined;
|
|
44
|
+
if (value.trim() === "")
|
|
45
|
+
return undefined;
|
|
46
|
+
if (value.length > MAX_TOOL_NAME_LENGTH)
|
|
47
|
+
return undefined;
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function parseConfigObject(raw) {
|
|
51
|
+
if (!raw || typeof raw !== "object")
|
|
52
|
+
return {};
|
|
53
|
+
const obj = raw;
|
|
54
|
+
return {
|
|
55
|
+
models: parseModelsArray(obj.models),
|
|
56
|
+
imageAnalysisTool: parseImageAnalysisTool(obj.imageAnalysisTool),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function readConfigFile(configPath) {
|
|
60
|
+
if (!existsSync(configPath))
|
|
61
|
+
return null;
|
|
62
|
+
try {
|
|
63
|
+
const content = await readFile(configPath, "utf-8");
|
|
64
|
+
const parsed = JSON.parse(content);
|
|
65
|
+
return parseConfigObject(parsed);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Config: Precedence & Merging (project > user > defaults)
|
|
72
|
+
function selectWithPrecedence(projectValue, userValue, defaultValue) {
|
|
73
|
+
if (projectValue !== undefined) {
|
|
74
|
+
return { value: projectValue, source: "project" };
|
|
75
|
+
}
|
|
76
|
+
if (userValue !== undefined) {
|
|
77
|
+
return { value: userValue, source: "user" };
|
|
78
|
+
}
|
|
79
|
+
return { value: defaultValue, source: "default" };
|
|
80
|
+
}
|
|
81
|
+
async function loadPluginConfig(directory, log) {
|
|
82
|
+
const userConfig = await readConfigFile(getUserConfigPath());
|
|
83
|
+
const projectConfig = await readConfigFile(getProjectConfigPath(directory));
|
|
84
|
+
// Resolve models with precedence
|
|
85
|
+
const modelsResult = selectWithPrecedence(projectConfig?.models, userConfig?.models, undefined);
|
|
86
|
+
if (modelsResult.source !== "default") {
|
|
87
|
+
log(`Loaded models from ${modelsResult.source} config: ${modelsResult.value.join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
log(`Using default models: ${DEFAULT_MODEL_PATTERNS.join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
// Resolve imageAnalysisTool with precedence
|
|
93
|
+
const toolResult = selectWithPrecedence(projectConfig?.imageAnalysisTool, userConfig?.imageAnalysisTool, undefined);
|
|
94
|
+
if (toolResult.source !== "default") {
|
|
95
|
+
log(`Using imageAnalysisTool from ${toolResult.source} config: ${toolResult.value}`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
log(`Using default imageAnalysisTool: ${DEFAULT_IMAGE_ANALYSIS_TOOL}`);
|
|
99
|
+
}
|
|
100
|
+
pluginConfig = {
|
|
101
|
+
models: modelsResult.value,
|
|
102
|
+
imageAnalysisTool: toolResult.value,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Config: Accessors
|
|
106
|
+
function getConfiguredModels() {
|
|
107
|
+
return pluginConfig.models ?? DEFAULT_MODEL_PATTERNS;
|
|
108
|
+
}
|
|
109
|
+
function getImageAnalysisTool() {
|
|
110
|
+
return pluginConfig.imageAnalysisTool ?? DEFAULT_IMAGE_ANALYSIS_TOOL;
|
|
111
|
+
}
|
|
112
|
+
// Pattern Matching (supports wildcards: *, prefix*, *suffix, *contains*)
|
|
113
|
+
function matchesWildcardPattern(pattern, value) {
|
|
114
|
+
const p = pattern.toLowerCase();
|
|
115
|
+
const v = value.toLowerCase();
|
|
116
|
+
// Global wildcard
|
|
117
|
+
if (p === "*")
|
|
118
|
+
return true;
|
|
119
|
+
// Contains: *text*
|
|
120
|
+
if (p.startsWith("*") && p.endsWith("*") && p.length > 2) {
|
|
121
|
+
return v.includes(p.slice(1, -1));
|
|
122
|
+
}
|
|
123
|
+
// Prefix: text*
|
|
124
|
+
if (p.endsWith("*")) {
|
|
125
|
+
return v.startsWith(p.slice(0, -1));
|
|
126
|
+
}
|
|
127
|
+
// Suffix: *text
|
|
128
|
+
if (p.startsWith("*")) {
|
|
129
|
+
return v.endsWith(p.slice(1));
|
|
130
|
+
}
|
|
131
|
+
// Exact match
|
|
132
|
+
return v === p;
|
|
133
|
+
}
|
|
134
|
+
function matchesSinglePattern(pattern, model) {
|
|
135
|
+
// Global wildcard matches everything
|
|
136
|
+
if (pattern === "*")
|
|
137
|
+
return true;
|
|
138
|
+
const slashIndex = pattern.indexOf("/");
|
|
139
|
+
// No slash: match against both provider and model
|
|
140
|
+
if (slashIndex === -1) {
|
|
141
|
+
return (matchesWildcardPattern(pattern, model.modelID) ||
|
|
142
|
+
matchesWildcardPattern(pattern, model.providerID));
|
|
143
|
+
}
|
|
144
|
+
// With slash: match provider/model separately
|
|
145
|
+
const providerPattern = pattern.slice(0, slashIndex);
|
|
146
|
+
const modelPattern = pattern.slice(slashIndex + 1);
|
|
147
|
+
return (matchesWildcardPattern(providerPattern, model.providerID) &&
|
|
148
|
+
matchesWildcardPattern(modelPattern, model.modelID));
|
|
149
|
+
}
|
|
150
|
+
function modelMatchesAnyPattern(model) {
|
|
35
151
|
if (!model)
|
|
36
152
|
return false;
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
return (providerID.includes("minimax") ||
|
|
40
|
-
modelID.includes("minimax") ||
|
|
41
|
-
modelID.includes("abab") // Minimax model naming convention
|
|
42
|
-
);
|
|
153
|
+
const patterns = getConfiguredModels();
|
|
154
|
+
return patterns.some((pattern) => matchesSinglePattern(pattern, model));
|
|
43
155
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
156
|
+
// Type Guards
|
|
157
|
+
//
|
|
158
|
+
// Messages in OpenCode contain "parts" - an array of different content types:
|
|
159
|
+
// - TextPart: The user's typed text
|
|
160
|
+
// - FilePart: Attached files (images, PDFs, etc.) with mime type and URL
|
|
47
161
|
function isImageFilePart(part) {
|
|
48
162
|
if (part.type !== "file")
|
|
49
163
|
return false;
|
|
50
|
-
const
|
|
51
|
-
return SUPPORTED_MIME_TYPES.has(
|
|
164
|
+
const mime = part.mime?.toLowerCase() ?? "";
|
|
165
|
+
return SUPPORTED_MIME_TYPES.has(mime);
|
|
52
166
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Check if a part is a TextPart
|
|
55
|
-
*/
|
|
56
167
|
function isTextPart(part) {
|
|
57
168
|
return part.type === "text";
|
|
58
169
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
170
|
+
// Image Processing: URL Handlers
|
|
171
|
+
//
|
|
172
|
+
// Images can arrive via different URL schemes:
|
|
173
|
+
// - file:// → Already on disk, just need the local path
|
|
174
|
+
// - data: → Base64-encoded, must decode and save to temp file
|
|
175
|
+
// - http(s): → Remote URL, pass through for MCP tool to fetch directly
|
|
176
|
+
function handleFileUrl(url, filePart, log) {
|
|
177
|
+
// Image is already saved locally; strip the file:// prefix to get the path
|
|
178
|
+
const localPath = url.replace("file://", "");
|
|
179
|
+
log(`Image already on disk: ${localPath}`);
|
|
180
|
+
return { path: localPath, mime: filePart.mime, partId: filePart.id };
|
|
181
|
+
}
|
|
182
|
+
function parseBase64DataUrl(dataUrl) {
|
|
63
183
|
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
|
64
184
|
if (!match)
|
|
65
185
|
return null;
|
|
66
186
|
try {
|
|
67
|
-
return {
|
|
68
|
-
mime: match[1],
|
|
69
|
-
data: Buffer.from(match[2], "base64"),
|
|
70
|
-
};
|
|
187
|
+
return { mime: match[1], data: Buffer.from(match[2], "base64") };
|
|
71
188
|
}
|
|
72
189
|
catch {
|
|
73
190
|
return null;
|
|
74
191
|
}
|
|
75
192
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
193
|
+
async function handleDataUrl(url, filePart, log) {
|
|
194
|
+
// Pasted clipboard images arrive as base64 data URLs.
|
|
195
|
+
// Decode and save to a temp file so the MCP tool can read it.
|
|
196
|
+
const parsed = parseBase64DataUrl(url);
|
|
197
|
+
if (!parsed) {
|
|
198
|
+
log(`Failed to parse data URL for part ${filePart.id}`);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const savedPath = await saveImageToTemp(parsed.data, parsed.mime);
|
|
203
|
+
log(`Saved image to: ${savedPath}`);
|
|
204
|
+
return { path: savedPath, mime: parsed.mime, partId: filePart.id };
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
log(`Failed to save image: ${err}`);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function handleHttpUrl(url, filePart, log) {
|
|
212
|
+
// Remote URLs are passed directly to the MCP tool, which can fetch them itself.
|
|
213
|
+
// This avoids unnecessary network requests and disk I/O.
|
|
214
|
+
log(`Image is remote URL: ${url}`);
|
|
215
|
+
return { path: url, mime: filePart.mime, partId: filePart.id };
|
|
216
|
+
}
|
|
217
|
+
// Image Processing: File Operations
|
|
218
|
+
function getExtensionForMime(mime) {
|
|
80
219
|
return MIME_TO_EXTENSION[mime.toLowerCase()] ?? "png";
|
|
81
220
|
}
|
|
82
|
-
/**
|
|
83
|
-
* Ensure temp directory exists and return its path
|
|
84
|
-
*/
|
|
85
221
|
async function ensureTempDir() {
|
|
86
222
|
const dir = join(tmpdir(), TEMP_DIR_NAME);
|
|
87
223
|
await mkdir(dir, { recursive: true });
|
|
88
224
|
return dir;
|
|
89
225
|
}
|
|
90
|
-
/**
|
|
91
|
-
* Save image data to a temp file and return the path
|
|
92
|
-
*/
|
|
93
226
|
async function saveImageToTemp(data, mime) {
|
|
94
227
|
const tempDir = await ensureTempDir();
|
|
95
|
-
const
|
|
96
|
-
const filename = `${randomUUID()}.${ext}`;
|
|
228
|
+
const filename = `${randomUUID()}.${getExtensionForMime(mime)}`;
|
|
97
229
|
const filepath = join(tempDir, filename);
|
|
98
230
|
await writeFile(filepath, data);
|
|
99
231
|
return filepath;
|
|
100
232
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
233
|
+
// Image Processing: Main Processor
|
|
234
|
+
async function processImagePart(filePart, log) {
|
|
235
|
+
const url = filePart.url;
|
|
236
|
+
if (!url) {
|
|
237
|
+
log(`Skipping image part ${filePart.id}: no URL`);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
if (url.startsWith("file://")) {
|
|
241
|
+
return handleFileUrl(url, filePart, log);
|
|
242
|
+
}
|
|
243
|
+
if (url.startsWith("data:")) {
|
|
244
|
+
return handleDataUrl(url, filePart, log);
|
|
245
|
+
}
|
|
246
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
247
|
+
return handleHttpUrl(url, filePart, log);
|
|
248
|
+
}
|
|
249
|
+
log(`Unsupported URL scheme for part ${filePart.id}: ${url.substring(0, 50)}...`);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
async function extractImagesFromParts(parts, log) {
|
|
253
|
+
const savedImages = [];
|
|
254
|
+
for (const part of parts) {
|
|
255
|
+
if (!isImageFilePart(part))
|
|
256
|
+
continue;
|
|
257
|
+
const result = await processImagePart(part, log);
|
|
258
|
+
if (result) {
|
|
259
|
+
savedImages.push(result);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return savedImages;
|
|
263
|
+
}
|
|
264
|
+
// Prompt Generation
|
|
265
|
+
//
|
|
266
|
+
// Since the target model doesn't natively understand image attachments,
|
|
267
|
+
// we replace them with text instructions that tell the model to use an
|
|
268
|
+
// MCP tool (e.g., understand_image) with the file path or URL.
|
|
269
|
+
// The user's original text is preserved as "User's request: ...".
|
|
270
|
+
function generateInjectionPrompt(images, userText, toolName) {
|
|
271
|
+
if (images.length === 0)
|
|
106
272
|
return userText;
|
|
107
|
-
const isSingle =
|
|
108
|
-
const imageList =
|
|
273
|
+
const isSingle = images.length === 1;
|
|
274
|
+
const imageList = images
|
|
109
275
|
.map((img, idx) => `- Image ${idx + 1}: ${img.path}`)
|
|
110
276
|
.join("\n");
|
|
111
|
-
|
|
277
|
+
const imageCountText = isSingle ? "an image" : `${images.length} images`;
|
|
278
|
+
const imagePlural = isSingle ? "image is" : "images are";
|
|
279
|
+
const analyzeText = isSingle ? "this image" : "each image";
|
|
280
|
+
return `The user has shared ${imageCountText}. The ${imagePlural} saved at:
|
|
112
281
|
${imageList}
|
|
113
282
|
|
|
114
|
-
Use the \`
|
|
283
|
+
Use the \`${toolName}\` tool to analyze ${analyzeText}.
|
|
115
284
|
|
|
116
285
|
User's request: ${userText || "(analyze the image)"}`;
|
|
117
286
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// Skip if no URL
|
|
130
|
-
if (!url) {
|
|
131
|
-
log(`Skipping image part ${filePart.id}: no URL`);
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
// Handle file:// URLs - already on disk
|
|
135
|
-
if (url.startsWith("file://")) {
|
|
136
|
-
const localPath = url.replace("file://", "");
|
|
137
|
-
log(`Image already on disk: ${localPath}`);
|
|
138
|
-
savedImages.push({
|
|
139
|
-
path: localPath,
|
|
140
|
-
mime: filePart.mime,
|
|
141
|
-
partId: filePart.id,
|
|
142
|
-
});
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
// Handle data: URLs - need to save to disk
|
|
146
|
-
if (url.startsWith("data:")) {
|
|
147
|
-
const parsed = parseDataUrl(url);
|
|
148
|
-
if (!parsed) {
|
|
149
|
-
log(`Failed to parse data URL for part ${filePart.id}`);
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
try {
|
|
153
|
-
const savedPath = await saveImageToTemp(parsed.data, parsed.mime);
|
|
154
|
-
log(`Saved image to: ${savedPath}`);
|
|
155
|
-
savedImages.push({
|
|
156
|
-
path: savedPath,
|
|
157
|
-
mime: parsed.mime,
|
|
158
|
-
partId: filePart.id,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
catch (err) {
|
|
162
|
-
log(`Failed to save image: ${err}`);
|
|
163
|
-
}
|
|
164
|
-
continue;
|
|
287
|
+
// Message Transformation
|
|
288
|
+
//
|
|
289
|
+
// The transformation flow:
|
|
290
|
+
// 1. Find the last user message (most recent request)
|
|
291
|
+
// 2. Extract and save any images from its parts
|
|
292
|
+
// 3. Remove the image parts (they can't be sent to the model)
|
|
293
|
+
// 4. Replace/update the text part with injection instructions
|
|
294
|
+
function findLastUserMessage(messages) {
|
|
295
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
296
|
+
if (messages[i].info.role === "user") {
|
|
297
|
+
return { message: messages[i], index: i };
|
|
165
298
|
}
|
|
166
|
-
// Handle HTTP/HTTPS URLs - Minimax can use these directly
|
|
167
|
-
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
168
|
-
log(`Image is remote URL: ${url}`);
|
|
169
|
-
savedImages.push({
|
|
170
|
-
path: url,
|
|
171
|
-
mime: filePart.mime,
|
|
172
|
-
partId: filePart.id,
|
|
173
|
-
});
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
log(`Unsupported URL scheme for part ${filePart.id}: ${url.substring(0, 50)}...`);
|
|
177
299
|
}
|
|
178
|
-
return
|
|
300
|
+
return null;
|
|
179
301
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
302
|
+
function getModelFromMessage(message) {
|
|
303
|
+
const info = message.info;
|
|
304
|
+
return info.model;
|
|
305
|
+
}
|
|
306
|
+
function removeProcessedImageParts(parts, processedIds) {
|
|
307
|
+
// Remove image parts that were successfully processed; they've been converted
|
|
308
|
+
// to file paths in the injection prompt and the model can't interpret raw images.
|
|
309
|
+
return parts.filter((part) => !(part.type === "file" && processedIds.has(part.id)));
|
|
310
|
+
}
|
|
311
|
+
function updateOrCreateTextPart(message, newText) {
|
|
312
|
+
const textPartIndex = message.parts.findIndex(isTextPart);
|
|
313
|
+
if (textPartIndex !== -1) {
|
|
314
|
+
message.parts[textPartIndex].text = newText;
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const newTextPart = {
|
|
318
|
+
id: `transformed-${randomUUID()}`,
|
|
319
|
+
sessionID: message.info.sessionID,
|
|
320
|
+
messageID: message.info.id,
|
|
321
|
+
type: "text",
|
|
322
|
+
text: newText,
|
|
323
|
+
synthetic: true,
|
|
324
|
+
};
|
|
325
|
+
message.parts.unshift(newTextPart);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Plugin Export
|
|
183
329
|
export const MinimaxEasyVisionPlugin = async (input) => {
|
|
184
|
-
const { client } = input;
|
|
185
|
-
// Simple logging helper
|
|
330
|
+
const { client, directory } = input;
|
|
186
331
|
const log = (msg) => {
|
|
187
332
|
client.app
|
|
188
|
-
.log({
|
|
189
|
-
|
|
190
|
-
service: PLUGIN_NAME,
|
|
191
|
-
level: "info",
|
|
192
|
-
message: msg,
|
|
193
|
-
},
|
|
194
|
-
})
|
|
195
|
-
.catch(() => {
|
|
196
|
-
// Ignore logging errors
|
|
197
|
-
});
|
|
333
|
+
.log({ body: { service: PLUGIN_NAME, level: "info", message: msg } })
|
|
334
|
+
.catch(() => { });
|
|
198
335
|
};
|
|
336
|
+
await loadPluginConfig(directory, log);
|
|
199
337
|
log("Plugin initialized");
|
|
200
338
|
return {
|
|
201
|
-
/**
|
|
202
|
-
* Transform messages before they're sent to the LLM
|
|
203
|
-
* This is where we intercept images and inject the MCP tool instructions
|
|
204
|
-
*/
|
|
205
339
|
"experimental.chat.messages.transform": async (_input, output) => {
|
|
206
340
|
const { messages } = output;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
if (!lastUserMessage) {
|
|
218
|
-
return; // No user message to process
|
|
219
|
-
}
|
|
220
|
-
// Check if using Minimax model
|
|
221
|
-
const userInfo = lastUserMessage.info;
|
|
222
|
-
if (!isMinimaxModel(userInfo.model)) {
|
|
223
|
-
return; // Not a Minimax model, skip
|
|
224
|
-
}
|
|
225
|
-
log("Detected Minimax model, checking for images...");
|
|
226
|
-
// Check if there are any image parts
|
|
341
|
+
const result = findLastUserMessage(messages);
|
|
342
|
+
if (!result)
|
|
343
|
+
return;
|
|
344
|
+
const { message: lastUserMessage, index: lastUserIndex } = result;
|
|
345
|
+
const model = getModelFromMessage(lastUserMessage);
|
|
346
|
+
if (!modelMatchesAnyPattern(model))
|
|
347
|
+
return;
|
|
348
|
+
log("Model matched, checking for images...");
|
|
227
349
|
const hasImages = lastUserMessage.parts.some(isImageFilePart);
|
|
228
|
-
if (!hasImages)
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
350
|
+
if (!hasImages)
|
|
351
|
+
return;
|
|
231
352
|
log("Found images in message, processing...");
|
|
232
|
-
|
|
233
|
-
const savedImages = await processMessageImages(lastUserMessage.parts, log);
|
|
353
|
+
const savedImages = await extractImagesFromParts(lastUserMessage.parts, log);
|
|
234
354
|
if (savedImages.length === 0) {
|
|
235
355
|
log("No images were successfully saved");
|
|
236
356
|
return;
|
|
@@ -238,29 +358,13 @@ export const MinimaxEasyVisionPlugin = async (input) => {
|
|
|
238
358
|
log(`Saved ${savedImages.length} image(s), transforming message...`);
|
|
239
359
|
const existingTextPart = lastUserMessage.parts.find(isTextPart);
|
|
240
360
|
const userText = existingTextPart?.text ?? "";
|
|
241
|
-
const transformedText = generateInjectionPrompt(savedImages
|
|
242
|
-
const
|
|
243
|
-
lastUserMessage.parts = lastUserMessage.parts
|
|
244
|
-
|
|
245
|
-
if (textPartIndex !== -1) {
|
|
246
|
-
const textPart = lastUserMessage.parts[textPartIndex];
|
|
247
|
-
textPart.text = transformedText;
|
|
248
|
-
}
|
|
249
|
-
else {
|
|
250
|
-
const newTextPart = {
|
|
251
|
-
id: `transformed-${randomUUID()}`,
|
|
252
|
-
sessionID: lastUserMessage.info.sessionID,
|
|
253
|
-
messageID: lastUserMessage.info.id,
|
|
254
|
-
type: "text",
|
|
255
|
-
text: transformedText,
|
|
256
|
-
synthetic: true,
|
|
257
|
-
};
|
|
258
|
-
lastUserMessage.parts.unshift(newTextPart);
|
|
259
|
-
}
|
|
361
|
+
const transformedText = generateInjectionPrompt(savedImages, userText, getImageAnalysisTool());
|
|
362
|
+
const processedIds = new Set(savedImages.map((img) => img.partId));
|
|
363
|
+
lastUserMessage.parts = removeProcessedImageParts(lastUserMessage.parts, processedIds);
|
|
364
|
+
updateOrCreateTextPart(lastUserMessage, transformedText);
|
|
260
365
|
messages[lastUserIndex] = lastUserMessage;
|
|
261
366
|
log("Successfully injected image path instructions");
|
|
262
367
|
},
|
|
263
368
|
};
|
|
264
369
|
};
|
|
265
|
-
// Default export for OpenCode plugin loading
|
|
266
370
|
export default MinimaxEasyVisionPlugin;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-minimax-easy-vision",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "OpenCode plugin that enables vision support for Minimax models by saving pasted images and injecting MCP tool instructions",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|