opencode-tui-image-clipboard-fix 1.0.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenCode Image Storage Plugin Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # OpenCode TUI Image Clipboard Fix
2
+
3
+ 修复 OpenCode TUI 中图片粘贴和拖入的问题:自动将图片保存为本地文件,并替换 `[Image N]` 占位符为实际文件路径。
4
+
5
+ ## 🚀 一键安装
6
+
7
+ ```bash
8
+ curl -fsSL https://raw.githubusercontent.com/A11thwn/opencode-tui-image-clipboard-fix/main/install.sh | bash
9
+ ```
10
+
11
+ 安装完成后,重启 OpenCode 即可使用。
12
+
13
+ ## 📦 手动安装
14
+
15
+ 在 `~/.config/opencode/opencode.json` 的 `plugin` 数组中添加:
16
+
17
+ ```json
18
+ {
19
+ "plugin": ["github:A11thwn/opencode-tui-image-clipboard-fix"]
20
+ }
21
+ ```
22
+
23
+ ## ✨ 功能特性
24
+
25
+ - ✅ **自动保存图片**:将 base64 图片数据保存为本地文件
26
+ - ✅ **路径替换**:自动替换 `[Image 1]` 占位符为 `/path/to/image.png`
27
+ - ✅ **去重检测**:使用 SHA-256 哈希避免保存重复图片
28
+ - ✅ **LRU 清理**:当存储超过限制时自动删除最旧的图片
29
+ - ✅ **移除 FilePart**:避免不支持图片的模型报错
30
+ - ✅ **自动提示**:添加提示让模型使用 `read` 工具读取图片
31
+
32
+ ## 🔧 工作原理
33
+
34
+ ```
35
+ ┌─────────────────────────────────────────────────────┐
36
+ │ OpenCode TUI │
37
+ │ 用户粘贴/拖入图片 │
38
+ │ ↓ │
39
+ │ 生成 FilePart (url: "data:image/...;base64,...") │
40
+ │ 消息文本包含 [Image 1] 占位符 │
41
+ └─────────────────────────────────────────────────────┘
42
+
43
+ ┌─────────────────────────────────────────────────────┐
44
+ │ Image Clipboard Fix Plugin │
45
+ │ 1. 监听 chat.message hook │
46
+ │ 2. 检测图片 FilePart │
47
+ │ 3. 提取 base64 数据,保存为本地文件 │
48
+ │ 4. 替换文本中的 [Image N] 为实际路径 │
49
+ │ 5. 移除 FilePart(避免模型报错) │
50
+ │ 6. 添加提示让模型使用 read 工具读取图片 │
51
+ └─────────────────────────────────────────────────────┘
52
+
53
+ ┌─────────────────────────────────────────────────────┐
54
+ │ 最终消息 │
55
+ │ "请分析这张图片 /path/to/image.png │
56
+ │ [Image Reference: ...]" │
57
+ │ 模型使用 read 工具读取图片 │
58
+ └─────────────────────────────────────────────────────┘
59
+ ```
60
+
61
+ ## ⚙️ 配置
62
+
63
+ 默认配置:
64
+
65
+ - **存储目录**: `~/.local/share/opencode/storage/images`
66
+ - **最大存储**: 2048 MB
67
+ - **最小剩余空间**: 512 MB
68
+
69
+ ## 📝 支持的格式
70
+
71
+ - PNG (支持尺寸提取)
72
+ - JPEG/JPG
73
+ - GIF
74
+ - WebP
75
+
76
+ ## 🐛 解决的问题
77
+
78
+ 1. **粘贴图片识别问题**:将 base64 图片保存为文件,替换占位符为路径
79
+ 2. **模型不支持图片报错**:移除 FilePart,只发送文本路径
80
+ 3. **图片读取提示**:自动添加提示让模型使用 read 工具读取图片
81
+
82
+ ## 🔗 相关链接
83
+
84
+ - [OpenCode](https://opencode.ai)
85
+ - [GitHub Issues](https://github.com/A11thwn/opencode-tui-image-clipboard-fix/issues)
86
+
87
+ ## 📄 License
88
+
89
+ MIT
@@ -0,0 +1,86 @@
1
+ interface PluginInput {
2
+ client: any;
3
+ project: any;
4
+ directory: string;
5
+ worktree: string;
6
+ serverUrl: URL;
7
+ $: any;
8
+ }
9
+ interface FilePart {
10
+ id?: string;
11
+ sessionID?: string;
12
+ messageID?: string;
13
+ type: "file";
14
+ mime: string;
15
+ filename?: string;
16
+ url: string;
17
+ source?: {
18
+ type: string;
19
+ path: string;
20
+ text?: {
21
+ start: number;
22
+ end: number;
23
+ value: string;
24
+ };
25
+ };
26
+ }
27
+ interface TextPart {
28
+ id?: string;
29
+ sessionID?: string;
30
+ messageID?: string;
31
+ type: "text";
32
+ text: string;
33
+ source?: {
34
+ text?: {
35
+ start: number;
36
+ end: number;
37
+ value: string;
38
+ };
39
+ };
40
+ }
41
+ type Part = FilePart | TextPart | {
42
+ type: string;
43
+ [key: string]: any;
44
+ };
45
+ interface Message {
46
+ id?: string;
47
+ sessionID?: string;
48
+ role?: string;
49
+ [key: string]: any;
50
+ }
51
+ interface ChatMessageInput {
52
+ sessionID: string;
53
+ agent?: string;
54
+ model?: {
55
+ providerID: string;
56
+ modelID: string;
57
+ };
58
+ messageID?: string;
59
+ variant?: string;
60
+ }
61
+ interface ChatMessageOutput {
62
+ message: any;
63
+ parts: Part[];
64
+ }
65
+ interface MessagesTransformOutput {
66
+ messages: {
67
+ info: Message;
68
+ parts: Part[];
69
+ }[];
70
+ }
71
+ type Plugin = (input: PluginInput) => Promise<{
72
+ "chat.message"?: (input: ChatMessageInput, output: ChatMessageOutput) => Promise<void>;
73
+ "experimental.chat.messages.transform"?: (input: {}, output: MessagesTransformOutput) => Promise<void>;
74
+ command?: Record<string, () => Promise<void>>;
75
+ }>;
76
+ /**
77
+ * OpenCode Image Storage Plugin
78
+ *
79
+ * 功能:
80
+ * 1. 监听用户消息中的图片(粘贴或拖入)
81
+ * 2. 将 base64 图片保存为本地文件
82
+ * 3. 在发送给模型时替换 [Image N] 占位符为实际文件路径(不影响界面显示)
83
+ * 4. 移除 FilePart,只保留文本(避免不支持图片的模型报错)
84
+ */
85
+ export declare const ImageStoragePlugin: Plugin;
86
+ export default ImageStoragePlugin;
package/dist/index.js ADDED
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ImageStoragePlugin = void 0;
37
+ const os = __importStar(require("os"));
38
+ const path = __importStar(require("path"));
39
+ const storage_1 = require("./storage");
40
+ const utils_1 = require("./utils");
41
+ const DEFAULT_CONFIG = {
42
+ maxStorageMB: 2048,
43
+ minFreeSpaceMB: 512,
44
+ storageDir: path.join(os.homedir(), ".local/share/opencode/storage/images"),
45
+ };
46
+ // 存储每个消息的图片路径映射(messageID -> imagePaths)
47
+ const messageImagePaths = new Map();
48
+ /**
49
+ * OpenCode Image Storage Plugin
50
+ *
51
+ * 功能:
52
+ * 1. 监听用户消息中的图片(粘贴或拖入)
53
+ * 2. 将 base64 图片保存为本地文件
54
+ * 3. 在发送给模型时替换 [Image N] 占位符为实际文件路径(不影响界面显示)
55
+ * 4. 移除 FilePart,只保留文本(避免不支持图片的模型报错)
56
+ */
57
+ const ImageStoragePlugin = async ({ client, directory }) => {
58
+ const config = {
59
+ ...DEFAULT_CONFIG,
60
+ };
61
+ const storageManager = new storage_1.ImageStorageManager(config);
62
+ await storageManager.initialize();
63
+ /**
64
+ * 处理消息中的图片,保存并返回路径映射
65
+ * @param parts 消息的 parts 数组
66
+ * @param saveImages 是否保存图片(chat.message 时保存,transform 时不保存)
67
+ */
68
+ async function processImageParts(parts, saveImages = true) {
69
+ const imagePathMap = new Map();
70
+ // 查找所有图片 parts
71
+ const imageParts = parts.filter((p) => p.type === "file" &&
72
+ typeof p.mime === "string" &&
73
+ p.mime.startsWith("image/"));
74
+ if (imageParts.length === 0) {
75
+ return { modified: false, imagePaths: imagePathMap };
76
+ }
77
+ for (let i = 0; i < imageParts.length; i++) {
78
+ const imagePart = imageParts[i];
79
+ const imageIndex = i + 1;
80
+ const placeholder = `[Image ${imageIndex}]`;
81
+ try {
82
+ // 检查是否是 base64 data URL(粘贴的图片)
83
+ if (imagePart.url && imagePart.url.startsWith("data:image/")) {
84
+ if (saveImages) {
85
+ const imagePath = await storageManager.saveImageAndReturnPath(imagePart.url, `msg_${Date.now()}`);
86
+ if (imagePath) {
87
+ imagePathMap.set(placeholder, imagePath);
88
+ }
89
+ }
90
+ }
91
+ else if (imagePart.source?.path &&
92
+ imagePart.source.path !== "clipboard" &&
93
+ imagePart.source.path !== "") {
94
+ // 已经是文件路径(拖入的文件)
95
+ imagePathMap.set(placeholder, imagePart.source.path);
96
+ }
97
+ }
98
+ catch (error) {
99
+ console.error(`[ImageStoragePlugin] Error processing image ${imageIndex}:`, error);
100
+ }
101
+ }
102
+ return { modified: imagePathMap.size > 0, imagePaths: imagePathMap };
103
+ }
104
+ /**
105
+ * 修改文本内容:替换占位符、去重路径、添加提示
106
+ * 只在 transform hook 中使用,不影响界面显示
107
+ */
108
+ function modifyTextContent(text, imagePathMap) {
109
+ let newText = text;
110
+ const allPaths = Array.from(imagePathMap.values());
111
+ // 1. 先从文本中移除所有已知的图片路径(去重)
112
+ for (const imagePath of allPaths) {
113
+ const escapedPath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
114
+ const pathPattern = new RegExp(`\\s*${escapedPath}`, "g");
115
+ newText = newText.replace(pathPattern, "");
116
+ }
117
+ // 2. 替换 [Image N] 占位符为路径
118
+ for (const [placeholder, imagePath] of imagePathMap) {
119
+ const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
+ const placeholderPattern = new RegExp(escapedPlaceholder, "g");
121
+ newText = newText.replace(placeholderPattern, imagePath);
122
+ }
123
+ // 3. 清理多余的空格和换行
124
+ newText = newText.replace(/\s+/g, " ").trim();
125
+ // 4. 添加图片读取提示
126
+ const hint = `\n\n[Image Reference: The above path(s) point to local image file(s). Please use the "read" tool to view the image content.]`;
127
+ newText = newText + hint;
128
+ return newText;
129
+ }
130
+ /**
131
+ * 移除图片 FilePart(静默,不打印日志)
132
+ */
133
+ function removeImagePartsSilently(parts) {
134
+ for (let i = parts.length - 1; i >= 0; i--) {
135
+ const part = parts[i];
136
+ if (part.type === "file" &&
137
+ part.mime?.startsWith("image/")) {
138
+ parts.splice(i, 1);
139
+ }
140
+ }
141
+ }
142
+ return {
143
+ /**
144
+ * chat.message hook - 在用户消息发送时处理
145
+ * 只负责:1. 保存图片 2. 记录路径映射 3. 移除 FilePart
146
+ * 不修改 textPart.text,避免修改后的内容显示在界面上
147
+ */
148
+ "chat.message": async (input, output) => {
149
+ const { parts } = output;
150
+ const { modified, imagePaths } = await processImageParts(parts, true);
151
+ if (!modified) {
152
+ return;
153
+ }
154
+ // 保存图片路径映射,供 transform hook 使用
155
+ const messageKey = input.messageID || `msg_${Date.now()}`;
156
+ messageImagePaths.set(messageKey, imagePaths);
157
+ // 移除图片 FilePart(避免不支持图片的模型报错)
158
+ removeImagePartsSilently(parts);
159
+ },
160
+ /**
161
+ * experimental.chat.messages.transform hook - 在发送给模型前转换消息
162
+ * 在这里进行文本替换,确保模型收到的是完整的图片路径
163
+ * 这个修改只影响发送给模型的内容,不影响界面显示
164
+ */
165
+ "experimental.chat.messages.transform": async (input, output) => {
166
+ for (const message of output.messages) {
167
+ // 只处理用户消息
168
+ if (message.info?.role !== "user")
169
+ continue;
170
+ const { parts } = message;
171
+ // 尝试从缓存获取图片路径
172
+ let imagePaths;
173
+ if (message.info?.id) {
174
+ imagePaths = messageImagePaths.get(message.info.id);
175
+ }
176
+ // 如果缓存中没有,尝试从 parts 中提取(可能是历史消息)
177
+ if (!imagePaths || imagePaths.size === 0) {
178
+ const result = await processImageParts(parts, false);
179
+ if (result.modified) {
180
+ imagePaths = result.imagePaths;
181
+ }
182
+ }
183
+ // 如果有图片路径,替换文本中的占位符
184
+ if (imagePaths && imagePaths.size > 0) {
185
+ const textPart = parts.find((p) => p.type === "text");
186
+ if (textPart && textPart.text) {
187
+ textPart.text = modifyTextContent(textPart.text, imagePaths);
188
+ }
189
+ }
190
+ // 移除图片 FilePart
191
+ removeImagePartsSilently(parts);
192
+ }
193
+ },
194
+ command: {
195
+ "cleanup-images": async () => {
196
+ const deletedCount = await storageManager.cleanup();
197
+ console.log(`[ImageStoragePlugin] Deleted ${deletedCount} images`);
198
+ },
199
+ "show-storage": async () => {
200
+ const stats = await storageManager.getStats();
201
+ console.log("=== Image Storage Stats ===");
202
+ console.log(`Total Files: ${stats.totalFiles}`);
203
+ console.log(`Total Size: ${(0, utils_1.formatSize)(stats.totalSize)}`);
204
+ console.log(`Oldest File: ${stats.oldestFile || "N/A"}`);
205
+ console.log(`Newest File: ${stats.newestFile || "N/A"}`);
206
+ console.log(`Storage Dir: ${config.storageDir}`);
207
+ console.log(`Max Storage: ${config.maxStorageMB} MB`);
208
+ },
209
+ "clear-cache": async () => {
210
+ messageImagePaths.clear();
211
+ console.log(`[ImageStoragePlugin] Cleared image path cache`);
212
+ },
213
+ },
214
+ };
215
+ };
216
+ exports.ImageStoragePlugin = ImageStoragePlugin;
217
+ exports.default = exports.ImageStoragePlugin;
@@ -0,0 +1,55 @@
1
+ import { ImageMetadata, StorageStats, PluginConfig } from './types';
2
+ /**
3
+ * 图片存储管理器
4
+ *
5
+ * 功能:
6
+ * - 将 base64 图片保存为本地文件
7
+ * - 去重检测(通过哈希)
8
+ * - 自动清理(LRU 策略)
9
+ * - 元数据管理
10
+ */
11
+ export declare class ImageStorageManager {
12
+ private config;
13
+ private metadataFile;
14
+ private metadata;
15
+ constructor(config: PluginConfig);
16
+ /**
17
+ * 初始化存储目录
18
+ */
19
+ initialize(): Promise<void>;
20
+ /**
21
+ * 加载元数据
22
+ */
23
+ private loadMetadata;
24
+ /**
25
+ * 保存元数据
26
+ */
27
+ private saveMetadata;
28
+ /**
29
+ * 保存图片
30
+ *
31
+ * @param dataUrl - base64 data URL (data:image/png;base64,...)
32
+ * @returns 图片元数据,如果图片已存在则返回现有元数据
33
+ */
34
+ saveImage(dataUrl: string): Promise<ImageMetadata | null>;
35
+ /**
36
+ * 获取存储统计信息
37
+ */
38
+ getStats(): Promise<StorageStats>;
39
+ /**
40
+ * 检查并自动清理(LRU 策略)
41
+ */
42
+ checkAndCleanup(): Promise<void>;
43
+ /**
44
+ * 手动清理所有图片
45
+ */
46
+ cleanup(): Promise<number>;
47
+ /**
48
+ * 保存图片并返回路径
49
+ *
50
+ * @param dataUrl - base64 data URL
51
+ * @param messageID - 消息 ID(用于日志)
52
+ * @returns 保存的图片路径,如果失败则返回 null
53
+ */
54
+ saveImageAndReturnPath(dataUrl: string, messageID: string): Promise<string | null>;
55
+ }
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ImageStorageManager = void 0;
37
+ const fs = __importStar(require("fs/promises"));
38
+ const path = __importStar(require("path"));
39
+ const utils_1 = require("./utils");
40
+ /**
41
+ * 图片存储管理器
42
+ *
43
+ * 功能:
44
+ * - 将 base64 图片保存为本地文件
45
+ * - 去重检测(通过哈希)
46
+ * - 自动清理(LRU 策略)
47
+ * - 元数据管理
48
+ */
49
+ class ImageStorageManager {
50
+ constructor(config) {
51
+ this.config = config;
52
+ this.metadata = new Map();
53
+ this.metadataFile = path.join(config.storageDir, 'metadata.json');
54
+ }
55
+ /**
56
+ * 初始化存储目录
57
+ */
58
+ async initialize() {
59
+ await fs.mkdir(this.config.storageDir, { recursive: true });
60
+ await this.loadMetadata();
61
+ }
62
+ /**
63
+ * 加载元数据
64
+ */
65
+ async loadMetadata() {
66
+ try {
67
+ const data = await fs.readFile(this.metadataFile, 'utf-8');
68
+ const parsed = JSON.parse(data);
69
+ this.metadata = new Map(Object.entries(parsed));
70
+ }
71
+ catch {
72
+ this.metadata = new Map();
73
+ }
74
+ }
75
+ /**
76
+ * 保存元数据
77
+ */
78
+ async saveMetadata() {
79
+ const obj = Object.fromEntries(this.metadata);
80
+ await fs.writeFile(this.metadataFile, JSON.stringify(obj, null, 2));
81
+ }
82
+ /**
83
+ * 保存图片
84
+ *
85
+ * @param dataUrl - base64 data URL (data:image/png;base64,...)
86
+ * @returns 图片元数据,如果图片已存在则返回现有元数据
87
+ */
88
+ async saveImage(dataUrl) {
89
+ const imageInfo = (0, utils_1.extractImageInfo)(dataUrl);
90
+ if (!imageInfo) {
91
+ // Invalid data URL format
92
+ return null;
93
+ }
94
+ const hash = (0, utils_1.generateHash)(imageInfo.base64);
95
+ // 检查是否已存在相同图片
96
+ const existingImage = Array.from(this.metadata.values()).find(m => m.hash === hash);
97
+ if (existingImage) {
98
+ // Image already exists, returning cached
99
+ return existingImage;
100
+ }
101
+ const buffer = Buffer.from(imageInfo.base64, 'base64');
102
+ const mimeType = `image/${imageInfo.type}`;
103
+ const dimensions = (0, utils_1.getImageDimensions)(buffer, mimeType);
104
+ const timestamp = Date.now();
105
+ const filename = `${timestamp}_${hash}.${imageInfo.type}`;
106
+ const filepath = path.join(this.config.storageDir, filename);
107
+ await fs.writeFile(filepath, buffer);
108
+ const metadata = {
109
+ filename,
110
+ path: filepath,
111
+ size: imageInfo.size,
112
+ mimeType,
113
+ width: dimensions?.width,
114
+ height: dimensions?.height,
115
+ createdAt: new Date(timestamp).toISOString(),
116
+ hash,
117
+ };
118
+ this.metadata.set(filename, metadata);
119
+ await this.saveMetadata();
120
+ // Image saved successfully
121
+ // 检查是否需要清理
122
+ await this.checkAndCleanup();
123
+ return metadata;
124
+ }
125
+ /**
126
+ * 获取存储统计信息
127
+ */
128
+ async getStats() {
129
+ const files = Array.from(this.metadata.values());
130
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
131
+ const totalFiles = files.length;
132
+ const sorted = files.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
133
+ return {
134
+ totalSize,
135
+ totalFiles,
136
+ oldestFile: sorted[0]?.createdAt,
137
+ newestFile: sorted[sorted.length - 1]?.createdAt,
138
+ };
139
+ }
140
+ /**
141
+ * 检查并自动清理(LRU 策略)
142
+ */
143
+ async checkAndCleanup() {
144
+ const stats = await this.getStats();
145
+ const maxBytes = this.config.maxStorageMB * 1024 * 1024;
146
+ if (stats.totalSize <= maxBytes)
147
+ return;
148
+ const bytesToFree = stats.totalSize - maxBytes + (this.config.minFreeSpaceMB * 1024 * 1024);
149
+ let freedBytes = 0;
150
+ const sorted = Array.from(this.metadata.values()).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
151
+ for (const file of sorted) {
152
+ if (freedBytes >= bytesToFree)
153
+ break;
154
+ try {
155
+ await fs.unlink(file.path);
156
+ freedBytes += file.size;
157
+ this.metadata.delete(file.filename);
158
+ // Deleted old image for cleanup
159
+ }
160
+ catch (err) {
161
+ console.error(`[ImageStorageManager] Failed to delete ${file.path}:`, err);
162
+ }
163
+ }
164
+ await this.saveMetadata();
165
+ // Cleanup complete
166
+ }
167
+ /**
168
+ * 手动清理所有图片
169
+ */
170
+ async cleanup() {
171
+ const files = Array.from(this.metadata.keys());
172
+ let deletedCount = 0;
173
+ for (const filename of files) {
174
+ const meta = this.metadata.get(filename);
175
+ if (!meta)
176
+ continue;
177
+ try {
178
+ await fs.unlink(meta.path);
179
+ this.metadata.delete(filename);
180
+ deletedCount++;
181
+ }
182
+ catch (err) {
183
+ console.error(`[ImageStorageManager] Failed to delete ${meta.path}:`, err);
184
+ }
185
+ }
186
+ await this.saveMetadata();
187
+ return deletedCount;
188
+ }
189
+ /**
190
+ * 保存图片并返回路径
191
+ *
192
+ * @param dataUrl - base64 data URL
193
+ * @param messageID - 消息 ID(用于日志)
194
+ * @returns 保存的图片路径,如果失败则返回 null
195
+ */
196
+ async saveImageAndReturnPath(dataUrl, messageID) {
197
+ const metadata = await this.saveImage(dataUrl);
198
+ if (!metadata)
199
+ return null;
200
+ return metadata.path;
201
+ }
202
+ }
203
+ exports.ImageStorageManager = ImageStorageManager;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 图片元数据
3
+ */
4
+ export interface ImageMetadata {
5
+ filename: string;
6
+ path: string;
7
+ size: number;
8
+ mimeType: string;
9
+ width?: number;
10
+ height?: number;
11
+ createdAt: string;
12
+ hash: string;
13
+ }
14
+ /**
15
+ * 存储统计信息
16
+ */
17
+ export interface StorageStats {
18
+ totalSize: number;
19
+ totalFiles: number;
20
+ oldestFile?: string;
21
+ newestFile?: string;
22
+ }
23
+ /**
24
+ * 插件配置
25
+ */
26
+ export interface PluginConfig {
27
+ maxStorageMB: number;
28
+ minFreeSpaceMB: number;
29
+ storageDir: string;
30
+ }
31
+ /**
32
+ * 图片信息(从 data URL 提取)
33
+ */
34
+ export interface ImageInfo {
35
+ type: string;
36
+ size: number;
37
+ base64: string;
38
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,27 @@
1
+ import { ImageInfo } from './types';
2
+ /**
3
+ * 格式化字节大小为人类可读格式
4
+ */
5
+ export declare function formatSize(bytes: number): string;
6
+ /**
7
+ * 从 data URL 提取图片信息
8
+ */
9
+ export declare function extractImageInfo(dataUrl: string): ImageInfo | null;
10
+ /**
11
+ * 生成 base64 数据的哈希值
12
+ */
13
+ export declare function generateHash(base64: string): string;
14
+ /**
15
+ * 获取 PNG 图片尺寸
16
+ */
17
+ export declare function getPNGDimensions(buffer: Buffer): {
18
+ width: number;
19
+ height: number;
20
+ } | null;
21
+ /**
22
+ * 获取图片尺寸(目前仅支持 PNG)
23
+ */
24
+ export declare function getImageDimensions(buffer: Buffer, mimeType: string): {
25
+ width: number;
26
+ height: number;
27
+ } | null;
package/dist/utils.js ADDED
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatSize = formatSize;
4
+ exports.extractImageInfo = extractImageInfo;
5
+ exports.generateHash = generateHash;
6
+ exports.getPNGDimensions = getPNGDimensions;
7
+ exports.getImageDimensions = getImageDimensions;
8
+ const crypto_1 = require("crypto");
9
+ /**
10
+ * 格式化字节大小为人类可读格式
11
+ */
12
+ function formatSize(bytes) {
13
+ const units = ['B', 'KB', 'MB', 'GB'];
14
+ let size = bytes;
15
+ let unitIndex = 0;
16
+ while (size >= 1024 && unitIndex < units.length - 1) {
17
+ size /= 1024;
18
+ unitIndex++;
19
+ }
20
+ return `${size.toFixed(2)} ${units[unitIndex]}`;
21
+ }
22
+ /**
23
+ * 从 data URL 提取图片信息
24
+ */
25
+ function extractImageInfo(dataUrl) {
26
+ const match = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,(.+)$/);
27
+ if (!match)
28
+ return null;
29
+ const type = match[1];
30
+ const base64 = match[2];
31
+ const size = Buffer.from(base64, 'base64').byteLength;
32
+ return { type, size, base64 };
33
+ }
34
+ /**
35
+ * 生成 base64 数据的哈希值
36
+ */
37
+ function generateHash(base64) {
38
+ return (0, crypto_1.createHash)('sha256').update(base64).digest('hex').substring(0, 16);
39
+ }
40
+ /**
41
+ * 获取 PNG 图片尺寸
42
+ */
43
+ function getPNGDimensions(buffer) {
44
+ try {
45
+ // PNG signature check
46
+ if (buffer.toString('hex', 0, 8) !== '89504e470d0a1a0a') {
47
+ return null;
48
+ }
49
+ // IHDR chunk contains dimensions (starts at byte 16)
50
+ const width = buffer.readUInt32BE(16);
51
+ const height = buffer.readUInt32BE(20);
52
+ return { width, height };
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * 获取图片尺寸(目前仅支持 PNG)
60
+ */
61
+ function getImageDimensions(buffer, mimeType) {
62
+ if (mimeType === 'image/png') {
63
+ return getPNGDimensions(buffer);
64
+ }
65
+ return null;
66
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "opencode-tui-image-clipboard-fix",
3
+ "version": "1.0.5",
4
+ "description": "OpenCode TUI plugin to fix image paste/drag issues - saves images locally and replaces [Image N] with file paths",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "require": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "dev": "tsc --watch",
22
+ "clean": "rm -rf dist",
23
+ "prepublishOnly": "npm run clean && npm run build"
24
+ },
25
+ "keywords": [
26
+ "opencode",
27
+ "opencode-plugin",
28
+ "plugin",
29
+ "image",
30
+ "storage",
31
+ "base64",
32
+ "clipboard",
33
+ "paste",
34
+ "drag-drop",
35
+ "ai",
36
+ "llm"
37
+ ],
38
+ "author": "chanliang",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/chanliang/opencode-tui-image-clipboard-fix.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/chanliang/opencode-tui-image-clipboard-fix/issues"
46
+ },
47
+ "homepage": "https://github.com/chanliang/opencode-tui-image-clipboard-fix#readme",
48
+ "engines": {
49
+ "node": ">=18.0.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^20.0.0",
53
+ "typescript": "^5.0.0"
54
+ },
55
+ "peerDependencies": {
56
+ "@opencode-ai/plugin": ">=1.0.0"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "@opencode-ai/plugin": {
60
+ "optional": true
61
+ }
62
+ }
63
+ }