opencode-image 0.1.3 → 2.0.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 +25 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +147 -138
- package/package.json +14 -7
package/README.md
CHANGED
|
@@ -7,9 +7,31 @@
|
|
|
7
7
|
- 兼容任何 OpenAI 风格的 `/images/generations` 与 `/images/edits` 接口
|
|
8
8
|
- 跨平台缩放(基于 [sharp](https://sharp.pixelplumbing.com)),不依赖 `sips` 等系统命令
|
|
9
9
|
|
|
10
|
+
## 版本与兼容(重要)
|
|
11
|
+
|
|
12
|
+
| 版本 | 目标 opencode | 插件 API | npm 安装 |
|
|
13
|
+
|---|---|---|---|
|
|
14
|
+
| **2.x**(当前线,`next` tag) | opencode **2.0+** | `@opencode/plugin@2`(`Plugin.define` + `ctx.tool` / `ctx.session`) | `opencode-image@next` |
|
|
15
|
+
| 1.x(`latest` tag) | opencode **1.x** | `@opencode-ai/plugin` | `opencode-image@latest` |
|
|
16
|
+
|
|
17
|
+
- v2 的插件运行时**只认** `export default { id, effect | setup }`,v1 形态的插件在 v2 里加载不了,所以这是**大版本切换**。
|
|
18
|
+
- 本仓库 `publishConfig.tag` 固定为 `next`,直接 `npm publish` **不会**顶掉 `latest`,因此 **v1 老用户不会被自动升级到 2.x**。
|
|
19
|
+
- v1 线要出新补丁:`bun run release:legacy`(打 patch 到 `latest`)。
|
|
20
|
+
|
|
10
21
|
## 安装
|
|
11
22
|
|
|
12
|
-
|
|
23
|
+
opencode **2.0+**(v2 插件 API):
|
|
24
|
+
|
|
25
|
+
```jsonc
|
|
26
|
+
{
|
|
27
|
+
"$schema": "https://opencode.ai/config.json",
|
|
28
|
+
"plugin": [
|
|
29
|
+
["opencode-image@next", { "apiKey": "sk-..." }]
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
opencode **1.x**(保持老版本):
|
|
13
35
|
|
|
14
36
|
```jsonc
|
|
15
37
|
{
|
|
@@ -20,7 +42,8 @@
|
|
|
20
42
|
}
|
|
21
43
|
```
|
|
22
44
|
|
|
23
|
-
opencode 启动时会自动从 npm 安装插件到 `~/.cache/opencode/packages/opencode-image
|
|
45
|
+
opencode 启动时会自动从 npm 安装插件到 `~/.cache/opencode/packages/opencode-image.../node_modules/`。
|
|
46
|
+
|
|
24
47
|
|
|
25
48
|
## 配置项
|
|
26
49
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export declare const OpenCodeImagePlugin: Plugin;
|
|
1
|
+
import { Plugin } from "@opencode/plugin";
|
|
2
|
+
export declare const OpenCodeImagePlugin: Plugin.Plugin;
|
|
3
3
|
export default OpenCodeImagePlugin;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
2
|
+
import { Plugin } from "@opencode/plugin";
|
|
3
|
+
import { Schema } from "effect";
|
|
3
4
|
import * as fs5 from "node:fs";
|
|
4
5
|
import * as path5 from "node:path";
|
|
5
6
|
|
|
@@ -407,9 +408,9 @@ class ImageServer {
|
|
|
407
408
|
return Buffer.from(relative2.split(path4.sep).join("/"), "utf8").toString("base64url");
|
|
408
409
|
}
|
|
409
410
|
}
|
|
410
|
-
for (const [
|
|
411
|
+
for (const [id, registered] of this.extra) {
|
|
411
412
|
if (registered === file)
|
|
412
|
-
return
|
|
413
|
+
return id;
|
|
413
414
|
}
|
|
414
415
|
const id = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
415
416
|
this.extra.set(id, file);
|
|
@@ -461,9 +462,9 @@ class ImageServer {
|
|
|
461
462
|
const port = this.configuredPort || derivePort(path4.resolve(opts.outDir));
|
|
462
463
|
const server = http.createServer((req, res) => void this.handle(req, res));
|
|
463
464
|
server.unref();
|
|
464
|
-
const failure = await new Promise((
|
|
465
|
-
server.once("error",
|
|
466
|
-
server.listen(port, "127.0.0.1", () =>
|
|
465
|
+
const failure = await new Promise((resolve) => {
|
|
466
|
+
server.once("error", resolve);
|
|
467
|
+
server.listen(port, "127.0.0.1", () => resolve(undefined));
|
|
467
468
|
});
|
|
468
469
|
if (!failure) {
|
|
469
470
|
this.server = server;
|
|
@@ -515,15 +516,15 @@ class ImageServer {
|
|
|
515
516
|
const root = this.root;
|
|
516
517
|
if (!root)
|
|
517
518
|
return;
|
|
518
|
-
let
|
|
519
|
+
let relative;
|
|
519
520
|
try {
|
|
520
|
-
|
|
521
|
+
relative = Buffer.from(id, "base64url").toString("utf8");
|
|
521
522
|
} catch {
|
|
522
523
|
return;
|
|
523
524
|
}
|
|
524
|
-
if (!
|
|
525
|
+
if (!relative || relative.includes("\x00") || path4.isAbsolute(relative))
|
|
525
526
|
return;
|
|
526
|
-
const target = path4.resolve(root,
|
|
527
|
+
const target = path4.resolve(root, relative);
|
|
527
528
|
if (target === root || !target.startsWith(root + path4.sep))
|
|
528
529
|
return;
|
|
529
530
|
try {
|
|
@@ -574,9 +575,25 @@ class ImageServer {
|
|
|
574
575
|
}
|
|
575
576
|
|
|
576
577
|
// src/index.ts
|
|
577
|
-
var
|
|
578
|
-
|
|
579
|
-
|
|
578
|
+
var PLUGIN_ID = "opencode-image";
|
|
579
|
+
var GENERATE_DESCRIPTION = "Generate an image from a text prompt. This is the only supported way to create images in this session: " + "the endpoint and API key are already configured inside this plugin, so NEVER call the image API yourself " + "(no curl, no scripts) and never read the API key out of opencode.json. " + "The first line of the output is a Markdown image line (plus a separate 查看原图 link when displayMaxSize > 0); " + "you MUST copy those lines verbatim into your reply text, otherwise the user cannot see the picture. " + "If the output contains a line starting with [请转述给用户], relay that line to the user as well. " + "To refine a result, call image_edit with the returned path instead of generating a new image.";
|
|
580
|
+
var EDIT_DESCRIPTION = "Edit an existing image with a text instruction (image-to-image). Prefer this over generating a new image " + "when the user wants to adjust a picture: pass the exact path returned by a previous image_generate or " + "image_edit call to keep refining the same one. " + "Same rules: never call the image API yourself and never read the API key; copy the Markdown image line " + "(and the 查看原图 link, if present) from the output verbatim into your reply, and relay any line starting " + "with [请转述给用户].";
|
|
581
|
+
var CACHE_DESCRIPTION = "Inspect or clear the preview-thumbnail cache used for rendering images in the conversation. " + 'Use action="status" to report the cache directory, file count, total size and age of the oldest entry; ' + 'action="clean" to delete every cached preview (the generated images in outDir are NOT touched). ' + "This returns text only — do not turn it into a Markdown image line.";
|
|
582
|
+
var SYSTEM_RULE = "Image generation/editing: always use the `image_generate` / `image_edit` tools provided by the " + "opencode-image plugin — the endpoint and API key are already configured there. Never call the image API " + "directly, never read the API key from config, and do not use the imagegen skill or its CLI. Copy the " + "Markdown image line from that tool's output verbatim into your reply so the user can see the picture.";
|
|
583
|
+
var GenerateInput = Schema.Struct({
|
|
584
|
+
prompt: Schema.String,
|
|
585
|
+
filename: Schema.optional(Schema.String)
|
|
586
|
+
});
|
|
587
|
+
var EditInput = Schema.Struct({
|
|
588
|
+
image: Schema.String,
|
|
589
|
+
prompt: Schema.String,
|
|
590
|
+
filename: Schema.optional(Schema.String)
|
|
591
|
+
});
|
|
592
|
+
var CacheInput = Schema.Struct({
|
|
593
|
+
action: Schema.Literals(["status", "clean"])
|
|
594
|
+
});
|
|
595
|
+
function resolveDestination(filename, options, directory) {
|
|
596
|
+
const dir = resolveOutDir(directory, options.outDir);
|
|
580
597
|
if (filename) {
|
|
581
598
|
const target = path5.isAbsolute(filename) ? filename : path5.join(dir, filename);
|
|
582
599
|
return `${target.replace(/\.[A-Za-z0-9]+$/, "")}.png`;
|
|
@@ -586,123 +603,119 @@ function resolveDestination(filename, outDir, ctx) {
|
|
|
586
603
|
return path5.join(dir, `image-${stamp}-${suffix}.png`);
|
|
587
604
|
}
|
|
588
605
|
function asAttachment(file) {
|
|
589
|
-
return
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}
|
|
596
|
-
];
|
|
606
|
+
return {
|
|
607
|
+
type: "file",
|
|
608
|
+
mime: "image/png",
|
|
609
|
+
name: path5.basename(file),
|
|
610
|
+
uri: `data:image/png;base64,${fs5.readFileSync(file).toString("base64")}`
|
|
611
|
+
};
|
|
597
612
|
}
|
|
598
|
-
var OpenCodeImagePlugin =
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
613
|
+
var OpenCodeImagePlugin = Plugin.define({
|
|
614
|
+
id: PLUGIN_ID,
|
|
615
|
+
setup: async (ctx) => {
|
|
616
|
+
const options = resolveOptions(ctx.options);
|
|
617
|
+
const directory = ctx.location.directory;
|
|
618
|
+
const outDir = resolveOutDir(directory, options.outDir);
|
|
619
|
+
const cache = new PreviewCache(options.cacheDir, options.cacheLimit);
|
|
620
|
+
const server = new ImageServer(options.serverIdleMs, options.serverPort, cache);
|
|
621
|
+
const selfVersion = readOwnVersion();
|
|
622
|
+
let updateHint;
|
|
623
|
+
let updateChecked = false;
|
|
624
|
+
const upgradeNotice = async () => {
|
|
625
|
+
if (!options.updateCheck || updateChecked || !selfVersion)
|
|
626
|
+
return updateHint;
|
|
627
|
+
updateChecked = true;
|
|
628
|
+
updateHint = await checkForUpdate(selfVersion);
|
|
607
629
|
return updateHint;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
if (options.displayMaxSize > 0)
|
|
620
|
-
lines.push(`[查看原图](${server.url(file, 0)})`);
|
|
621
|
-
display = lines.join(`
|
|
630
|
+
};
|
|
631
|
+
const render = async (label, prompt, file, progress, usage) => {
|
|
632
|
+
const { width, height } = await imageDimensions(file);
|
|
633
|
+
let display;
|
|
634
|
+
try {
|
|
635
|
+
await server.start({ outDir });
|
|
636
|
+
const alt = prompt.slice(0, 60);
|
|
637
|
+
const lines = [`})`];
|
|
638
|
+
if (options.displayMaxSize > 0)
|
|
639
|
+
lines.push(`[查看原图](${server.url(file, 0)})`);
|
|
640
|
+
display = lines.join(`
|
|
622
641
|
`);
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
`),
|
|
642
|
-
|
|
643
|
-
|
|
642
|
+
} catch (error) {
|
|
643
|
+
display = `[opencode-image: local image server unavailable, cannot render inline: ${String(error)}]`;
|
|
644
|
+
}
|
|
645
|
+
const notice = await upgradeNotice();
|
|
646
|
+
const output = [
|
|
647
|
+
display,
|
|
648
|
+
`${label} saved to ${file} (${width}x${height})`,
|
|
649
|
+
`Refine it by calling image_edit with image="${file}" and a prompt describing the change.`,
|
|
650
|
+
`IMPORTANT: copy the Markdown image line above verbatim into your reply text, otherwise the user cannot see the picture.`
|
|
651
|
+
];
|
|
652
|
+
if (notice)
|
|
653
|
+
output.push(notice);
|
|
654
|
+
const cacheNotice = server.consumeCacheNotice();
|
|
655
|
+
if (cacheNotice)
|
|
656
|
+
output.push(cacheNotice);
|
|
657
|
+
progress(`${label}: ${prompt.slice(0, 60)}`);
|
|
658
|
+
return {
|
|
659
|
+
content: [{ type: "text", text: output.join(`
|
|
660
|
+
`) }, asAttachment(file)],
|
|
661
|
+
metadata: { file, prompt, usage }
|
|
662
|
+
};
|
|
644
663
|
};
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
image_cache: tool({
|
|
696
|
-
description: "Inspect or clear the preview-thumbnail cache used for rendering images in the conversation. " + 'Use action="status" to report the cache directory, file count, total size and age of the oldest entry; ' + 'action="clean" to delete every cached preview (the generated images in outDir are NOT touched). ' + "This returns text only — do not turn it into a Markdown image line.",
|
|
697
|
-
args: {
|
|
698
|
-
action: z.enum(["status", "clean"]).describe("status = 查看缓存;clean = 清空缓存")
|
|
699
|
-
},
|
|
664
|
+
await ctx.tool.transform((tools) => {
|
|
665
|
+
tools.add({
|
|
666
|
+
name: "image_generate",
|
|
667
|
+
description: GENERATE_DESCRIPTION,
|
|
668
|
+
input: GenerateInput,
|
|
669
|
+
options: { codemode: false },
|
|
670
|
+
execute: async (args, toolCtx) => {
|
|
671
|
+
const title = `Generating image: ${args.prompt.slice(0, 60)}`;
|
|
672
|
+
await toolCtx.progress({ title });
|
|
673
|
+
const { signal, clear } = withTimeout(toolCtx.signal, options.timeoutMs);
|
|
674
|
+
try {
|
|
675
|
+
const remote = await generateImage(options, args.prompt, signal);
|
|
676
|
+
const raw = await fetchImageBytes(remote, signal);
|
|
677
|
+
const destination = resolveDestination(args.filename, options, directory);
|
|
678
|
+
await persistImage(raw, destination, options.maxSize);
|
|
679
|
+
return await render("Image generated", args.prompt, destination, (m) => void toolCtx.progress({ title: m }), remote.usage);
|
|
680
|
+
} finally {
|
|
681
|
+
clear();
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
tools.add({
|
|
686
|
+
name: "image_edit",
|
|
687
|
+
description: EDIT_DESCRIPTION,
|
|
688
|
+
input: EditInput,
|
|
689
|
+
options: { codemode: false },
|
|
690
|
+
execute: async (args, toolCtx) => {
|
|
691
|
+
const source = path5.isAbsolute(args.image) ? args.image : path5.resolve(directory, args.image);
|
|
692
|
+
if (!fs5.existsSync(source))
|
|
693
|
+
throw new Error(`image_edit: source image not found: ${source}`);
|
|
694
|
+
const title = `Editing image: ${args.prompt.slice(0, 60)}`;
|
|
695
|
+
await toolCtx.progress({ title });
|
|
696
|
+
const { signal, clear } = withTimeout(toolCtx.signal, options.timeoutMs);
|
|
697
|
+
try {
|
|
698
|
+
const upload = await toPngBuffer(source, options.maxSize);
|
|
699
|
+
const remote = await editImage(options, upload, `${path5.basename(source, path5.extname(source))}.png`, args.prompt, signal);
|
|
700
|
+
const raw = await fetchImageBytes(remote, signal);
|
|
701
|
+
const destination = resolveDestination(args.filename, options, directory);
|
|
702
|
+
await persistImage(raw, destination, options.maxSize);
|
|
703
|
+
return await render("Image edited", args.prompt, destination, (m) => void toolCtx.progress({ title: m }), remote.usage);
|
|
704
|
+
} finally {
|
|
705
|
+
clear();
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
tools.add({
|
|
710
|
+
name: "image_cache",
|
|
711
|
+
description: CACHE_DESCRIPTION,
|
|
712
|
+
input: CacheInput,
|
|
713
|
+
options: { codemode: false },
|
|
700
714
|
execute: async (args) => {
|
|
701
715
|
if (args.action === "clean") {
|
|
702
716
|
const removed = server.cleanCache();
|
|
703
717
|
return {
|
|
704
|
-
|
|
705
|
-
output: [
|
|
718
|
+
content: [
|
|
706
719
|
`已清理 ${removed.count} 个预览文件,共 ${humanBytes(removed.bytes)}。`,
|
|
707
720
|
`缓存目录:${removed.dir}`,
|
|
708
721
|
`注意:这不影响 outDir 里的原图,会话里的图片仍能正常显示(需要时会重新生成缩略图)。`
|
|
@@ -713,8 +726,7 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
|
|
|
713
726
|
}
|
|
714
727
|
const stats = server.cacheStats();
|
|
715
728
|
return {
|
|
716
|
-
|
|
717
|
-
output: [
|
|
729
|
+
content: [
|
|
718
730
|
`缓存目录:${stats.dir}`,
|
|
719
731
|
`文件数量:${stats.count}`,
|
|
720
732
|
`占用大小:${humanBytes(stats.bytes)}`,
|
|
@@ -725,20 +737,17 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
|
|
|
725
737
|
metadata: stats
|
|
726
738
|
};
|
|
727
739
|
}
|
|
728
|
-
})
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
}
|
|
738
|
-
};
|
|
739
|
-
};
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
if (options.injectPrompt)
|
|
743
|
+
await ctx.session.hook("context", (event) => {
|
|
744
|
+
event.system.push({ type: "text", text: SYSTEM_RULE });
|
|
745
|
+
});
|
|
746
|
+
return () => server.stop();
|
|
747
|
+
}
|
|
748
|
+
});
|
|
740
749
|
var src_default = OpenCodeImagePlugin;
|
|
741
750
|
export {
|
|
742
|
-
|
|
743
|
-
|
|
751
|
+
OpenCodeImagePlugin,
|
|
752
|
+
src_default as default
|
|
744
753
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-image",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Image generation and editing tools for opencode, with inline rendering in the conversation.",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Image generation and editing tools for opencode (v2 plugin API), with inline rendering in the conversation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"registry": "https://registry.npmjs.org/",
|
|
26
|
-
"access": "public"
|
|
26
|
+
"access": "public",
|
|
27
|
+
"tag": "next"
|
|
27
28
|
},
|
|
28
29
|
"exports": {
|
|
29
30
|
".": {
|
|
@@ -39,25 +40,31 @@
|
|
|
39
40
|
],
|
|
40
41
|
"scripts": {
|
|
41
42
|
"build": "bun run build:js && bun run build:types",
|
|
42
|
-
"build:js": "bun build src/index.ts --target=node --format=esm --external sharp --external @opencode
|
|
43
|
+
"build:js": "bun build src/index.ts --target=node --format=esm --external sharp --external @opencode/plugin --external effect --outfile=dist/index.js",
|
|
43
44
|
"build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
|
|
44
45
|
"typecheck": "tsc --noEmit",
|
|
45
46
|
"test": "bun test",
|
|
46
47
|
"prepublishOnly": "bun run build && bun test",
|
|
47
|
-
"release": "npm
|
|
48
|
+
"release": "npm publish && rm -rf ~/.cache/opencode/packages/opencode-image*",
|
|
49
|
+
"release:legacy": "npm version patch && npm publish --tag latest"
|
|
48
50
|
},
|
|
49
51
|
"engines": {
|
|
50
52
|
"node": ">=18"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
|
53
|
-
"@opencode-ai/plugin": "^1.18.30",
|
|
54
55
|
"sharp": "^0.35.4"
|
|
55
56
|
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@opencode/plugin": ">=2.0.27",
|
|
59
|
+
"effect": "^4.0.0"
|
|
60
|
+
},
|
|
56
61
|
"devDependencies": {
|
|
62
|
+
"@opencode/plugin": "2.0.27",
|
|
57
63
|
"@types/node": "^24.12.2",
|
|
64
|
+
"effect": "4.0.1",
|
|
58
65
|
"typescript": "^5.8.2"
|
|
59
66
|
},
|
|
60
67
|
"bin": {
|
|
61
|
-
"opencode-image": "
|
|
68
|
+
"opencode-image": "bin/cli.js"
|
|
62
69
|
}
|
|
63
70
|
}
|