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 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** when using [MiniMax](https://www.minimax.io/) models. It restores a simple “paste and ask” workflow by automatically handling image assets and routing them through the [MiniMax Coding Plan MCP](https://github.com/MiniMax-AI/MiniMax-Coding-Plan-MCP)
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 (for example, MiniMax M2.1) inside OpenCode, users run into a limitation: **vision is not supported via native image attachments**.
19
+ When using MiniMax models (like MiniMax M2.1) in OpenCode, native image attachments aren't supported.
16
20
 
17
- MiniMax models rely on the MiniMax Coding Plan MCPs `understand_image` tool, which requires an explicit file path or URL. This breaks the normal chat workflow:
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**: Images pasted directly into chat are ignored by MiniMax models.
20
- * **Manual steps**: Users must save screenshots, locate file paths, and reference them manually.
21
- * **Broken flow**: The paste and ask vision workflow available in other models is lost.
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 removes that friction by automating the vision pipeline for MiniMax models.
29
+ This plugin automates the vision pipeline so you don't have to think about it.
26
30
 
27
- Internally, it:
31
+ **How it works:**
28
32
 
29
- 1. Detects when a MiniMax model is active
30
- 2. Intercepts images pasted into the chat
31
- 3. Saves them to a temporary local directory
32
- 4. Injects the required context so the model can invoke the `understand_image` MCP tool with the correct file path
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
- From the user’s perspective, pasted images simply work with MiniMax vision.
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
- The plugin activates only for MiniMax models, identified by:
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
- Non-MiniMax models are not affected. Their native vision support continues to work normally.
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
- These formats match the constraints of the MiniMax Coding Plan MCP `understand_image` tool.
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
- Add the plugin to the `plugin` array in your `opencode.json` file:
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 local source
153
+ ### From Local Source
72
154
 
73
- 1. Clone or download this repository
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 file to your OpenCode plugin directory:
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. Start OpenCode with a supported MiniMax model
109
- 2. Paste an image into the chat (`Cmd+V` / `Ctrl+V`)
110
- 3. Ask a question about the image
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
- ## Limitations
187
+ ### Example Interaction
134
188
 
135
- * Uses `experimental.chat.messages.transform`, which may change in future OpenCode versions
136
- * Images persist until the OS clears the temporary directory
137
- * Only JPEG, PNG, and WebP are supported
138
- * The MCP server must have access to the local filesystem
139
- * Animated GIFs and unsupported formats are skipped
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 `LICENSE.md` for details.
207
+ GPL-3.0. See [LICENSE.md](./LICENSE.md)
153
208
 
154
209
  ## References
155
210
 
156
- * [https://opencode.ai](https://opencode.ai)
157
- * [https://opencode.ai/docs/plugins/](https://opencode.ai/docs/plugins/)
158
- * [https://www.minimax.io/](https://www.minimax.io/)
159
- * [https://github.com/MiniMax-AI/MiniMax-Coding-Plan-MCP](https://github.com/MiniMax-AI/MiniMax-Coding-Plan-MCP)
160
- * [https://platform.minimax.io/docs/guides/coding-plan-mcp-guide](https://platform.minimax.io/docs/guides/coding-plan-mcp-guide)
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
@@ -1,7 +1,4 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
- /**
3
- * The main plugin export
4
- */
5
2
  export declare const MinimaxEasyVisionPlugin: Plugin;
6
3
  export default MinimaxEasyVisionPlugin;
7
4
  //# sourceMappingURL=index.d.ts.map
@@ -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;AAsNlD;;GAEG;AACH,eAAO,MAAM,uBAAuB,EAAE,MAgHrC,CAAC;AAGF,eAAe,uBAAuB,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
- * Supported image MIME types (Minimax MCP limitation)
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
- * Check if a model is a Minimax model
33
- */
34
- function isMinimaxModel(model) {
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 providerID = model.providerID.toLowerCase();
38
- const modelID = model.modelID.toLowerCase();
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
- * Check if a part is a FilePart with an image
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 filePart = part;
51
- return SUPPORTED_MIME_TYPES.has(filePart.mime?.toLowerCase() ?? "");
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
- * Parse a data URL and extract the base64 data
61
- */
62
- function parseDataUrl(dataUrl) {
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
- * Get file extension from MIME type
78
- */
79
- function getExtension(mime) {
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 ext = getExtension(mime);
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
- * Generate the injection prompt for the model
103
- */
104
- function generateInjectionPrompt(imagePaths, userText) {
105
- if (imagePaths.length === 0)
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 = imagePaths.length === 1;
108
- const imageList = imagePaths
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
- return `The user has shared ${isSingle ? "an image" : `${imagePaths.length} images`}. The ${isSingle ? "image is" : "images are"} saved at:
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 \`mcp_minimax_understand_image\` tool to analyze ${isSingle ? "this image" : "each image"}. Pass the file path as \`image_source\` and describe what to look for in \`prompt\`.
283
+ Use the \`${toolName}\` tool to analyze ${analyzeText}.
115
284
 
116
285
  User's request: ${userText || "(analyze the image)"}`;
117
286
  }
118
- /**
119
- * Process a message and extract/save any images
120
- * Returns the paths of saved images
121
- */
122
- async function processMessageImages(parts, log) {
123
- const savedImages = [];
124
- for (const part of parts) {
125
- if (!isImageFilePart(part))
126
- continue;
127
- const filePart = part;
128
- const url = filePart.url;
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 savedImages;
300
+ return null;
179
301
  }
180
- /**
181
- * The main plugin export
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
- body: {
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
- // Find the last user message
208
- let lastUserMessage;
209
- let lastUserIndex = -1;
210
- for (let i = messages.length - 1; i >= 0; i--) {
211
- if (messages[i].info.role === "user") {
212
- lastUserMessage = messages[i];
213
- lastUserIndex = i;
214
- break;
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; // No images to process
230
- }
350
+ if (!hasImages)
351
+ return;
231
352
  log("Found images in message, processing...");
232
- // Process and save images
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.map((img) => ({ path: img.path, mime: img.mime })), userText);
242
- const processedPartIds = new Set(savedImages.map((img) => img.partId));
243
- lastUserMessage.parts = lastUserMessage.parts.filter((part) => !(part.type === "file" && processedPartIds.has(part.id)));
244
- const textPartIndex = lastUserMessage.parts.findIndex(isTextPart);
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.0.0",
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",