opencode-image 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-image 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,84 @@
1
+ # opencode-image
2
+
3
+ 给 [opencode](https://opencode.ai) 用的图片生成 / 编辑插件,支持把生成的图片**直接渲染在对话里**。
4
+
5
+ - `image_generate` — 文生图
6
+ - `image_edit` — 图生图(基于上一张继续改)
7
+ - 兼容任何 OpenAI 风格的 `/images/generations` 与 `/images/edits` 接口
8
+ - 跨平台缩放(基于 [sharp](https://sharp.pixelplumbing.com)),不依赖 `sips` 等系统命令
9
+
10
+ ## 安装
11
+
12
+ 在 `opencode.json` / `opencode.jsonc` 里配置:
13
+
14
+ ```jsonc
15
+ {
16
+ "$schema": "https://opencode.ai/config.json",
17
+ "plugin": [
18
+ ["opencode-image", { "apiKey": "sk-..." }]
19
+ ]
20
+ }
21
+ ```
22
+
23
+ opencode 启动时会自动从 npm 安装插件到 `~/.cache/opencode/packages/opencode-image/node_modules/`。
24
+
25
+ ## 配置项
26
+
27
+ `plugin` 数组的元组形式第二个元素就是插件配置:
28
+
29
+ | 字段 | 默认值 | 说明 |
30
+ |---|---|---|
31
+ | `apiKey` | `PI_IMAGE_API_KEY` | **必填**。接口密钥,建议用环境变量而不是写进配置 |
32
+ | `baseURL` | `PI_IMAGE_BASE_URL` 或内置默认 | OpenAI 兼容接口地址 |
33
+ | `model` | `gpt-image-2` | 模型名 |
34
+ | `outDir` | `<项目目录>/.opencode-images` | 图片输出目录,相对路径基于当前项目 |
35
+ | `maxSize` | `1024` | 落盘图片最长边上限,超出等比缩小 |
36
+ | `displayMaxSize` | `448` | 仅在对话里展示的尺寸,不影响落盘原图 |
37
+ | `serverIdleMs` | `0` | 本地图片服务空闲关闭时间(毫秒),`0` = 不主动关闭 |
38
+ | `timeoutMs` | `600000` | 单次请求超时 |
39
+
40
+ ## 图片是怎么显示出来的
41
+
42
+ openCode(桌面端 / Web)**只在助手回复的 Markdown 正文里渲染图片**,工具输出一律按纯文本处理。
43
+ 而正文里的 `file://` 会被 Chromium 拦掉,所以插件做了这件事:
44
+
45
+ 1. 图片落盘到 `outDir`
46
+ 2. 插件进程内起一个只绑 `127.0.0.1` 的 HTTP 服务,为图片分配随机 id
47
+ 3. 工具输出第一行给出 `![alt](http://127.0.0.1:<port>/img/<id>?w=448)`
48
+ 4. 工具描述里强制模型把这行**原样抄进回复正文**
49
+
50
+ 服务的安全边界:只服务显式注册过的文件,URL 里没有真实路径,无法路径穿越读取任意文件。
51
+
52
+ 服务的生命周期:**首次生成图片时懒启动**,端口随机;`unref()` 后不阻塞 opencode 退出,
53
+ opencode 进程结束即自动释放。默认不主动关闭——历史消息里的图片会被重新请求,提前关闭会 404。
54
+
55
+ > 同时在工具结果里返回了 `attachments`,这样模型自己也能看到生成的图,便于继续迭代。
56
+
57
+ ## 环境变量
58
+
59
+ ```bash
60
+ export PI_IMAGE_API_KEY=sk-...
61
+ export PI_IMAGE_BASE_URL=https://api.example.com/v1
62
+ ```
63
+
64
+ ## 平台支持
65
+
66
+ 缩放使用 `sharp`(Node-API 原生模块,npm 会自动装对应平台的预编译包),
67
+ macOS / Linux / Windows 均可。
68
+
69
+ ## 开发
70
+
71
+ ```bash
72
+ bun install
73
+ bun test # 端到端测试:桩掉图像接口,验证落盘、缩放、服务与安全边界
74
+ bun run build # 产出 dist/index.js + dist/index.d.ts
75
+ ```
76
+
77
+ ## 已知限制
78
+
79
+ - 只有能渲染 Markdown 图片的客户端才看得到图;纯终端 TUI 里会退化成一串 URL。
80
+ - 预览图缓存在系统临时目录,`serverIdleMs = 0`(默认)时随进程退出由系统回收。
81
+
82
+ ## License
83
+
84
+ MIT
package/dist/api.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ export type RemoteImage = {
2
+ url?: string;
3
+ b64?: string;
4
+ usage?: unknown;
5
+ };
6
+ /** 从 JSON 或 SSE(data: 行)响应里取出第一张图片 */
7
+ export declare function parseImageResponse(body: string): RemoteImage;
8
+ /** 把外部 AbortSignal 与超时合并成一个可清理的 signal */
9
+ export declare function withTimeout(external: AbortSignal, ms: number): {
10
+ signal: AbortSignal;
11
+ clear: () => void;
12
+ };
13
+ type RequestConfig = {
14
+ baseURL: string;
15
+ apiKey: string;
16
+ model: string;
17
+ timeoutMs: number;
18
+ };
19
+ /** 下载图片 URL 或 base64 内容为字节 */
20
+ export declare function fetchImageBytes(image: RemoteImage, signal: AbortSignal): Promise<Buffer>;
21
+ export declare function generateImage(config: RequestConfig, prompt: string, signal: AbortSignal): Promise<RemoteImage>;
22
+ export declare function editImage(config: RequestConfig, source: Buffer, filename: string, prompt: string, signal: AbortSignal): Promise<RemoteImage>;
23
+ export {};
@@ -0,0 +1,24 @@
1
+ export type Dimensions = {
2
+ width: number;
3
+ height: number;
4
+ };
5
+ /** resize 参数:等比缩到最长边不超过 max,且绝不放大 */
6
+ export declare function fitInside(max: number): {
7
+ width: number;
8
+ height: number;
9
+ fit: "inside";
10
+ withoutEnlargement: boolean;
11
+ };
12
+ /** 读取图片尺寸,不是合法图片时抛错 */
13
+ export declare function imageDimensions(file: string): Promise<Dimensions>;
14
+ /** 读取任意图片并按需缩放,统一输出 PNG 字节(用于上传接口,mime 才准确) */
15
+ export declare function toPngBuffer(source: string, maxSize: number): Promise<Buffer>;
16
+ /** 生成等比缩放的 PNG 预览;原图本身不超过 max 时直接返回原图路径 */
17
+ export declare function renderPreview(source: string, destination: string, max: number): Promise<string>;
18
+ /**
19
+ * 把接口返回的原始字节归一化为 PNG 落盘:
20
+ * 1. 先写 staging 文件,再从落盘字节读尺寸(避免半截文件被当成成品)
21
+ * 2. 超过 maxSize 时等比缩小
22
+ * 3. 统一转 PNG,保证扩展名、mime 与实际字节一致
23
+ */
24
+ export declare function persistImage(raw: Buffer, destination: string, maxSize: number): Promise<Dimensions>;
@@ -0,0 +1,3 @@
1
+ import { type Plugin } from "@opencode-ai/plugin";
2
+ export declare const OpenCodeImagePlugin: Plugin;
3
+ export default OpenCodeImagePlugin;
package/dist/index.js ADDED
@@ -0,0 +1,392 @@
1
+ // src/index.ts
2
+ import { tool } from "@opencode-ai/plugin";
3
+ import * as fs3 from "node:fs";
4
+ import * as path3 from "node:path";
5
+
6
+ // src/api.ts
7
+ function parseImageResponse(body) {
8
+ const trimmed = body.trim();
9
+ if (trimmed.startsWith("{")) {
10
+ return fromPayload(JSON.parse(trimmed));
11
+ }
12
+ for (const line of trimmed.split(/\r?\n/)) {
13
+ const raw = line.trim();
14
+ if (!raw.startsWith("data:"))
15
+ continue;
16
+ const payload = raw.slice(5).trim();
17
+ if (!payload || payload === "[DONE]")
18
+ continue;
19
+ try {
20
+ return fromPayload(JSON.parse(payload));
21
+ } catch {
22
+ continue;
23
+ }
24
+ }
25
+ throw new Error(`opencode-image: no image in response: ${trimmed.slice(0, 500)}`);
26
+ }
27
+ function fromPayload(obj) {
28
+ const item = obj?.data?.[0] ?? obj;
29
+ if (item?.b64_json)
30
+ return { b64: item.b64_json, usage: obj?.usage ?? item?.usage };
31
+ if (typeof item?.url === "string")
32
+ return { url: item.url, usage: obj?.usage ?? item?.usage };
33
+ throw new Error(`opencode-image: no image in response payload: ${JSON.stringify(obj).slice(0, 500)}`);
34
+ }
35
+ function withTimeout(external, ms) {
36
+ const controller = new AbortController;
37
+ const timer = setTimeout(() => controller.abort(new Error(`timed out after ${ms}ms`)), ms);
38
+ const forward = () => controller.abort(external.reason);
39
+ if (external.aborted)
40
+ forward();
41
+ else
42
+ external.addEventListener("abort", forward, { once: true });
43
+ return {
44
+ signal: controller.signal,
45
+ clear: () => {
46
+ clearTimeout(timer);
47
+ external.removeEventListener("abort", forward);
48
+ }
49
+ };
50
+ }
51
+ async function readImage(res, endpoint) {
52
+ const body = await res.text();
53
+ if (!res.ok) {
54
+ throw new Error(`opencode-image: ${endpoint} HTTP ${res.status}: ${body.slice(0, 500)}`);
55
+ }
56
+ return parseImageResponse(body);
57
+ }
58
+ async function fetchImageBytes(image, signal) {
59
+ if (image.b64)
60
+ return Buffer.from(image.b64, "base64");
61
+ if (!image.url)
62
+ throw new Error("opencode-image: response has neither url nor b64_json");
63
+ const res = await fetch(image.url, { signal });
64
+ if (!res.ok)
65
+ throw new Error(`opencode-image: image download failed: HTTP ${res.status}`);
66
+ return Buffer.from(await res.arrayBuffer());
67
+ }
68
+ async function generateImage(config, prompt, signal) {
69
+ const res = await fetch(`${config.baseURL}/images/generations`, {
70
+ method: "POST",
71
+ headers: { Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json" },
72
+ body: JSON.stringify({ model: config.model, prompt, n: 1 }),
73
+ signal
74
+ });
75
+ return readImage(res, "POST /images/generations");
76
+ }
77
+ async function editImage(config, source, filename, prompt, signal) {
78
+ const form = new FormData;
79
+ form.append("model", config.model);
80
+ form.append("prompt", prompt);
81
+ form.append("image", new Blob([new Uint8Array(source)], { type: "image/png" }), filename);
82
+ const res = await fetch(`${config.baseURL}/images/edits`, {
83
+ method: "POST",
84
+ headers: { Authorization: `Bearer ${config.apiKey}` },
85
+ body: form,
86
+ signal
87
+ });
88
+ return readImage(res, "POST /images/edits");
89
+ }
90
+
91
+ // src/image.ts
92
+ import * as fs from "node:fs";
93
+ import * as path from "node:path";
94
+ import sharp from "sharp";
95
+ function fitInside(max) {
96
+ return { width: max, height: max, fit: "inside", withoutEnlargement: true };
97
+ }
98
+ async function imageDimensions(file) {
99
+ const meta = await sharp(file).metadata();
100
+ if (!meta.width || !meta.height) {
101
+ throw new Error(`opencode-image: cannot read image dimensions: ${file}`);
102
+ }
103
+ return { width: meta.width, height: meta.height };
104
+ }
105
+ async function toPngBuffer(source, maxSize) {
106
+ const current = await imageDimensions(source);
107
+ const pipeline = Math.max(current.width, current.height) > maxSize ? sharp(source).resize(fitInside(maxSize)) : sharp(source);
108
+ return pipeline.png().toBuffer();
109
+ }
110
+ async function renderPreview(source, destination, max) {
111
+ const current = await imageDimensions(source);
112
+ if (Math.max(current.width, current.height) <= max)
113
+ return source;
114
+ await sharp(source).resize(fitInside(max)).png().toFile(destination);
115
+ return destination;
116
+ }
117
+ async function persistImage(raw, destination, maxSize) {
118
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
119
+ const staging = `${destination}.${process.pid}-${Date.now()}.download`;
120
+ fs.writeFileSync(staging, raw);
121
+ try {
122
+ const meta = await sharp(staging).metadata();
123
+ const width = meta.width ?? 0;
124
+ const height = meta.height ?? 0;
125
+ if (!width || !height)
126
+ throw new Error("opencode-image: response is not a decodable image");
127
+ const pipeline = Math.max(width, height) > maxSize ? sharp(staging).resize(fitInside(maxSize)) : sharp(staging);
128
+ await pipeline.png().toFile(destination);
129
+ } finally {
130
+ fs.rmSync(staging, { force: true });
131
+ }
132
+ return imageDimensions(destination);
133
+ }
134
+
135
+ // src/options.ts
136
+ var DEFAULT_BASE_URL = "https://pi-ai.roiscloud.com/v1";
137
+ var DEFAULT_MODEL = "gpt-image-2";
138
+ var DEFAULT_MAX_SIZE = 1024;
139
+ var DEFAULT_DISPLAY_MAX_SIZE = 448;
140
+ var DEFAULT_SERVER_IDLE_MS = 0;
141
+ var DEFAULT_TIMEOUT_MS = 600000;
142
+ function resolveOptions(options) {
143
+ const opts = options ?? {};
144
+ const apiKey = opts.apiKey ?? process.env.PI_IMAGE_API_KEY;
145
+ if (!apiKey) {
146
+ throw new Error("opencode-image: missing apiKey. Pass it in the plugin options or set PI_IMAGE_API_KEY.");
147
+ }
148
+ return {
149
+ baseURL: (opts.baseURL ?? process.env.PI_IMAGE_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
150
+ apiKey,
151
+ model: opts.model ?? DEFAULT_MODEL,
152
+ outDir: opts.outDir,
153
+ maxSize: opts.maxSize ?? DEFAULT_MAX_SIZE,
154
+ displayMaxSize: opts.displayMaxSize ?? DEFAULT_DISPLAY_MAX_SIZE,
155
+ serverIdleMs: opts.serverIdleMs ?? DEFAULT_SERVER_IDLE_MS,
156
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
157
+ };
158
+ }
159
+
160
+ // src/server.ts
161
+ import * as fs2 from "node:fs";
162
+ import * as http from "node:http";
163
+ import * as os from "node:os";
164
+ import * as path2 from "node:path";
165
+ import { randomUUID } from "node:crypto";
166
+ var MIME_BY_EXT = {
167
+ ".png": "image/png",
168
+ ".jpg": "image/jpeg",
169
+ ".jpeg": "image/jpeg",
170
+ ".webp": "image/webp",
171
+ ".gif": "image/gif"
172
+ };
173
+
174
+ class ImageServer {
175
+ idleMs;
176
+ server;
177
+ port = 0;
178
+ registry = new Map;
179
+ previews = new Map;
180
+ timer;
181
+ starting;
182
+ constructor(idleMs) {
183
+ this.idleMs = idleMs;
184
+ }
185
+ get baseURL() {
186
+ return this.port ? `http://127.0.0.1:${this.port}` : "";
187
+ }
188
+ register(file) {
189
+ const id = randomUUID().replace(/-/g, "").slice(0, 16);
190
+ const mime = MIME_BY_EXT[path2.extname(file).toLowerCase()] ?? "application/octet-stream";
191
+ this.registry.set(id, { file, mime });
192
+ return id;
193
+ }
194
+ url(id, width) {
195
+ return `${this.baseURL}/img/${id}?w=${width}`;
196
+ }
197
+ async start() {
198
+ if (this.server) {
199
+ this.arm();
200
+ return;
201
+ }
202
+ if (!this.starting) {
203
+ this.starting = this.listen().finally(() => {
204
+ this.starting = undefined;
205
+ });
206
+ }
207
+ await this.starting;
208
+ this.arm();
209
+ }
210
+ stop() {
211
+ if (this.timer)
212
+ clearTimeout(this.timer);
213
+ this.timer = undefined;
214
+ const server = this.server;
215
+ this.server = undefined;
216
+ this.port = 0;
217
+ server?.closeAllConnections?.();
218
+ server?.close();
219
+ for (const file of this.previews.values())
220
+ fs2.rmSync(file, { force: true });
221
+ this.previews.clear();
222
+ }
223
+ async listen() {
224
+ const server = http.createServer((req, res) => void this.handle(req, res));
225
+ server.unref();
226
+ await new Promise((resolve, reject) => {
227
+ server.once("error", reject);
228
+ server.listen(0, "127.0.0.1", resolve);
229
+ });
230
+ const address = server.address();
231
+ const port = typeof address === "object" && address ? address.port : 0;
232
+ if (!port) {
233
+ server.close();
234
+ throw new Error("opencode-image: image server did not bind a port");
235
+ }
236
+ this.server = server;
237
+ this.port = port;
238
+ }
239
+ arm() {
240
+ if (this.timer)
241
+ clearTimeout(this.timer);
242
+ if (this.idleMs <= 0)
243
+ return;
244
+ this.timer = setTimeout(() => this.stop(), this.idleMs);
245
+ this.timer.unref();
246
+ }
247
+ preview(source, max) {
248
+ const key = `${source}@${max}`;
249
+ const cached = this.previews.get(key);
250
+ if (cached && fs2.existsSync(cached))
251
+ return Promise.resolve(cached);
252
+ const destination = path2.join(os.tmpdir(), `opencode-image-${process.pid}-${randomUUID()}.png`);
253
+ return renderPreview(source, destination, max).then((file) => {
254
+ if (file !== source)
255
+ this.previews.set(key, file);
256
+ return file;
257
+ });
258
+ }
259
+ handle(req, res) {
260
+ this.arm();
261
+ const parsed = new URL(req.url ?? "/", "http://127.0.0.1");
262
+ const id = /^\/img\/([0-9a-f]{8,64})$/.exec(parsed.pathname)?.[1];
263
+ const entry = id ? this.registry.get(id) : undefined;
264
+ if (!entry || !fs2.existsSync(entry.file)) {
265
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
266
+ res.end("not found");
267
+ return;
268
+ }
269
+ this.send(entry, Number(parsed.searchParams.get("w")), res);
270
+ }
271
+ async send(entry, width, res) {
272
+ try {
273
+ const target = width > 0 ? await this.preview(entry.file, Math.round(width)) : entry.file;
274
+ const body = fs2.readFileSync(target);
275
+ res.writeHead(200, {
276
+ "content-type": entry.mime,
277
+ "content-length": body.length,
278
+ "cache-control": "private, max-age=86400"
279
+ });
280
+ res.end(body);
281
+ } catch (error) {
282
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
283
+ res.end(`opencode-image: cannot serve image: ${String(error)}`);
284
+ }
285
+ }
286
+ }
287
+
288
+ // src/index.ts
289
+ var z = tool.schema;
290
+ function resolveDestination(filename, outDir, ctx) {
291
+ const dir = outDir ? path3.isAbsolute(outDir) ? outDir : path3.join(ctx.directory, outDir) : path3.join(ctx.directory, ".opencode-images");
292
+ if (filename) {
293
+ const target = path3.isAbsolute(filename) ? filename : path3.join(dir, filename);
294
+ return `${target.replace(/\.[A-Za-z0-9]+$/, "")}.png`;
295
+ }
296
+ const stamp = new Date().toISOString().replace(/\..+$/, "").replace(/[:T]/g, "-");
297
+ const suffix = Math.random().toString(36).slice(2, 6);
298
+ return path3.join(dir, `image-${stamp}-${suffix}.png`);
299
+ }
300
+ function asAttachment(file) {
301
+ return [
302
+ {
303
+ type: "file",
304
+ mime: "image/png",
305
+ filename: path3.basename(file),
306
+ url: `data:image/png;base64,${fs3.readFileSync(file).toString("base64")}`
307
+ }
308
+ ];
309
+ }
310
+ var OpenCodeImagePlugin = async (_input, rawOptions) => {
311
+ const options = resolveOptions(rawOptions);
312
+ const server = new ImageServer(options.serverIdleMs);
313
+ const result = async (label, prompt, file, usage) => {
314
+ const { width, height } = await imageDimensions(file);
315
+ const id = server.register(file);
316
+ let display;
317
+ try {
318
+ await server.start();
319
+ display = `![${prompt.slice(0, 60)}](${server.url(id, options.displayMaxSize)})`;
320
+ } catch (error) {
321
+ display = `[opencode-image: local image server unavailable, cannot render inline: ${String(error)}]`;
322
+ }
323
+ return {
324
+ title: `${label}: ${prompt.slice(0, 60)}`,
325
+ output: [
326
+ display,
327
+ `${label} saved to ${file} (${width}x${height})`,
328
+ `Refine it by calling image_edit with image="${file}" and a prompt describing the change.`,
329
+ `IMPORTANT: copy the Markdown image line above verbatim into your reply text, otherwise the user cannot see the picture.`
330
+ ].join(`
331
+ `),
332
+ metadata: { file, prompt, usage },
333
+ attachments: asAttachment(file)
334
+ };
335
+ };
336
+ const generate = async (args, ctx) => {
337
+ ctx.metadata({ title: `Generating image: ${args.prompt.slice(0, 60)}` });
338
+ const { signal, clear } = withTimeout(ctx.abort, options.timeoutMs);
339
+ try {
340
+ const remote = await generateImage(options, args.prompt, signal);
341
+ const raw = await fetchImageBytes(remote, signal);
342
+ const destination = resolveDestination(args.filename, options.outDir, ctx);
343
+ await persistImage(raw, destination, options.maxSize);
344
+ return result("Image generated", args.prompt, destination, remote.usage);
345
+ } finally {
346
+ clear();
347
+ }
348
+ };
349
+ const edit = async (args, ctx) => {
350
+ const source = path3.isAbsolute(args.image) ? args.image : path3.resolve(ctx.directory, args.image);
351
+ if (!fs3.existsSync(source))
352
+ throw new Error(`image_edit: source image not found: ${source}`);
353
+ ctx.metadata({ title: `Editing image: ${args.prompt.slice(0, 60)}` });
354
+ const { signal, clear } = withTimeout(ctx.abort, options.timeoutMs);
355
+ try {
356
+ const upload = await toPngBuffer(source, options.maxSize);
357
+ const remote = await editImage(options, upload, `${path3.basename(source, path3.extname(source))}.png`, args.prompt, signal);
358
+ const raw = await fetchImageBytes(remote, signal);
359
+ const destination = resolveDestination(args.filename, options.outDir, ctx);
360
+ await persistImage(raw, destination, options.maxSize);
361
+ return result("Image edited", args.prompt, destination, remote.usage);
362
+ } finally {
363
+ clear();
364
+ }
365
+ };
366
+ return {
367
+ tool: {
368
+ image_generate: tool({
369
+ description: "Generate a new image from a text prompt using an OpenAI-compatible image API. " + "The result includes a Markdown image line that you MUST copy verbatim into your reply text, " + "otherwise the user cannot see the picture. To iterate on a result, call image_edit with the returned path.",
370
+ args: {
371
+ prompt: z.string().describe("Detailed description of the image to generate"),
372
+ filename: z.string().optional().describe("Optional output file name or path; defaults to a timestamped name")
373
+ },
374
+ execute: generate
375
+ }),
376
+ image_edit: tool({
377
+ description: "Edit an existing image with a text instruction (image-to-image). Pass the exact path returned by a previous " + "image_generate or image_edit call to keep refining the same picture. " + "The result includes a Markdown image line that you MUST copy verbatim into your reply text, " + "otherwise the user cannot see the picture.",
378
+ args: {
379
+ image: z.string().describe("Path to the source image, normally the file returned by image_generate/image_edit"),
380
+ prompt: z.string().describe("What to change, e.g. 'make the hat red, keep everything else identical'"),
381
+ filename: z.string().optional().describe("Optional output file name or path; defaults to a timestamped name")
382
+ },
383
+ execute: edit
384
+ })
385
+ }
386
+ };
387
+ };
388
+ var src_default = OpenCodeImagePlugin;
389
+ export {
390
+ src_default as default,
391
+ OpenCodeImagePlugin
392
+ };
@@ -0,0 +1,37 @@
1
+ /** 插件可配置项,通过 opencode 配置的 `plugin` 元组第二个元素传入 */
2
+ export type PluginOptions = {
3
+ /** OpenAI 兼容的图像接口地址,默认从 PI_IMAGE_BASE_URL 读取 */
4
+ baseURL?: string;
5
+ /** 接口密钥,默认从 PI_IMAGE_API_KEY 读取 */
6
+ apiKey?: string;
7
+ /** 图像模型名 */
8
+ model?: string;
9
+ /** 图片输出目录;相对路径基于当前项目目录,默认 <project>/.opencode-images */
10
+ outDir?: string;
11
+ /** 落盘图片的最长边上限(像素),超出会等比缩小 */
12
+ maxSize?: number;
13
+ /** 在对话里展示的图片最长边(像素);只影响展示,不影响落盘原图 */
14
+ displayMaxSize?: number;
15
+ /** 本地图片服务的空闲关闭时间(毫秒),0 表示不主动关闭 */
16
+ serverIdleMs?: number;
17
+ /** 单次图像请求超时(毫秒) */
18
+ timeoutMs?: number;
19
+ };
20
+ export declare const DEFAULT_BASE_URL = "https://pi-ai.roiscloud.com/v1";
21
+ export declare const DEFAULT_MODEL = "gpt-image-2";
22
+ export declare const DEFAULT_MAX_SIZE = 1024;
23
+ export declare const DEFAULT_DISPLAY_MAX_SIZE = 448;
24
+ export declare const DEFAULT_SERVER_IDLE_MS = 0;
25
+ export declare const DEFAULT_TIMEOUT_MS = 600000;
26
+ export type ResolvedOptions = {
27
+ baseURL: string;
28
+ apiKey: string;
29
+ model: string;
30
+ outDir?: string;
31
+ maxSize: number;
32
+ displayMaxSize: number;
33
+ serverIdleMs: number;
34
+ timeoutMs: number;
35
+ };
36
+ /** 合并默认值、环境变量与用户配置;缺少 apiKey 时直接抛错 */
37
+ export declare function resolveOptions(options: PluginOptions | undefined): ResolvedOptions;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 本地图片服务:把生成结果通过 http://127.0.0.1:<port>/img/<id>?w=<width> 暴露给客户端渲染。
3
+ *
4
+ * 客户端限制说明:图片只有出现在助手回复的 Markdown 正文里才会渲染,
5
+ * 而正文里的 file:// 会被 Chromium 拦截,所以必须走本地 HTTP。
6
+ *
7
+ * 安全边界:只服务显式 register() 过的文件,id 为随机值,URL 里没有真实路径,
8
+ * 因此无法通过路径穿越读取任意文件。
9
+ */
10
+ export declare class ImageServer {
11
+ private readonly idleMs;
12
+ private server?;
13
+ private port;
14
+ private registry;
15
+ private previews;
16
+ private timer?;
17
+ private starting?;
18
+ constructor(idleMs: number);
19
+ get baseURL(): string;
20
+ /** 注册文件并返回可访问的 id */
21
+ register(file: string): string;
22
+ url(id: string, width: number): string;
23
+ /** 懒启动:首次需要时监听随机端口;unref 后不阻塞宿主进程退出 */
24
+ start(): Promise<void>;
25
+ /**
26
+ * 关闭服务并清理预览缓存。
27
+ * 默认不调用:历史消息里的图片会重新请求,提前关闭会 404。
28
+ */
29
+ stop(): void;
30
+ private listen;
31
+ private arm;
32
+ private preview;
33
+ private handle;
34
+ private send;
35
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "opencode-image",
3
+ "version": "0.1.0",
4
+ "description": "Image generation and editing tools for opencode, with inline rendering in the conversation.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "opencode",
9
+ "opencode-plugin",
10
+ "image",
11
+ "image-generation",
12
+ "gpt-image"
13
+ ],
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/jianhao/opencode-image.git"
19
+ },
20
+ "homepage": "https://github.com/jianhao/opencode-image#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/jianhao/opencode-image/issues"
23
+ },
24
+ "publishConfig": {
25
+ "registry": "https://registry.npmjs.org/",
26
+ "access": "public"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "import": "./dist/index.js",
31
+ "types": "./dist/index.d.ts"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "build": "bun run build:js && bun run build:types",
41
+ "build:js": "bun build src/index.ts --target=node --format=esm --external sharp --external @opencode-ai/plugin --outfile=dist/index.js",
42
+ "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "bun test",
45
+ "prepublishOnly": "bun run build && bun test"
46
+ },
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "dependencies": {
51
+ "@opencode-ai/plugin": "^1.18.30",
52
+ "sharp": "^0.35.4"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^24.12.2",
56
+ "typescript": "^5.8.2"
57
+ }
58
+ }