opencode-image 0.1.0 → 0.1.1
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 +43 -2
- package/bin/cli.js +79 -0
- package/dist/index.js +210 -53
- package/dist/options.d.ts +8 -0
- package/dist/server.d.ts +29 -9
- package/dist/update.d.ts +9 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -34,7 +34,9 @@ opencode 启动时会自动从 npm 安装插件到 `~/.cache/opencode/packages/o
|
|
|
34
34
|
| `outDir` | `<项目目录>/.opencode-images` | 图片输出目录,相对路径基于当前项目 |
|
|
35
35
|
| `maxSize` | `1024` | 落盘图片最长边上限,超出等比缩小 |
|
|
36
36
|
| `displayMaxSize` | `448` | 仅在对话里展示的尺寸,不影响落盘原图 |
|
|
37
|
+
| `serverPort` | `0` | 本地图片服务端口。`0` = 由项目目录派生(同一项目恒定,保证历史链接跨重启有效) |
|
|
37
38
|
| `serverIdleMs` | `0` | 本地图片服务空闲关闭时间(毫秒),`0` = 不主动关闭 |
|
|
39
|
+
| `updateCheck` | `true` | 首次出图时查一次 npm 最新版,落后则在工具输出末尾附升级提示 |
|
|
38
40
|
| `timeoutMs` | `600000` | 单次请求超时 |
|
|
39
41
|
|
|
40
42
|
## 图片是怎么显示出来的
|
|
@@ -43,17 +45,54 @@ openCode(桌面端 / Web)**只在助手回复的 Markdown 正文里渲染图
|
|
|
43
45
|
而正文里的 `file://` 会被 Chromium 拦掉,所以插件做了这件事:
|
|
44
46
|
|
|
45
47
|
1. 图片落盘到 `outDir`
|
|
46
|
-
2. 插件进程内起一个只绑 `127.0.0.1` 的 HTTP
|
|
48
|
+
2. 插件进程内起一个只绑 `127.0.0.1` 的 HTTP 服务
|
|
47
49
|
3. 工具输出第一行给出 ``
|
|
48
50
|
4. 工具描述里强制模型把这行**原样抄进回复正文**
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
**链接为什么跨重启还有效**:`id` 是「文件相对 `outDir` 的路径」的 base64url 编码,可逆且稳定;
|
|
53
|
+
端口默认由项目目录派生(同一项目恒定)。所以服务**不需要任何内存登记表**,
|
|
54
|
+
重启后历史消息里的旧链接仍能解析到同一个文件。
|
|
55
|
+
|
|
56
|
+
服务的安全边界:请求里的 `id` 只允许解析成 `outDir` 内部的普通文件,
|
|
57
|
+
解析后做 containment + `realpath` 双重校验,防路径穿越与符号链接逃逸。
|
|
58
|
+
`outDir` 之外的文件只能用进程内临时 id(不跨重启)。
|
|
51
59
|
|
|
52
60
|
服务的生命周期:**首次生成图片时懒启动**,端口随机;`unref()` 后不阻塞 opencode 退出,
|
|
53
61
|
opencode 进程结束即自动释放。默认不主动关闭——历史消息里的图片会被重新请求,提前关闭会 404。
|
|
54
62
|
|
|
55
63
|
> 同时在工具结果里返回了 `attachments`,这样模型自己也能看到生成的图,便于继续迭代。
|
|
56
64
|
|
|
65
|
+
## 升级
|
|
66
|
+
|
|
67
|
+
opencode 对 npm 形式的插件**不会自动升级**。
|
|
68
|
+
|
|
69
|
+
它的安装逻辑是 `Npm.add()`:只要缓存目录里已经有这个包就直接复用,不会去比对版本
|
|
70
|
+
(opencode 源码 `packages/core/src/npm.ts` 里的短路分支)。而裸包名会被规范化成
|
|
71
|
+
`opencode-image@latest`,缓存目录名固定为 `~/.cache/opencode/packages/opencode-image@latest`,
|
|
72
|
+
所以你会一直停留在**第一次安装到的版本**。
|
|
73
|
+
|
|
74
|
+
升级只需要两条:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
npx opencode-image --update # 清理 opencode 的插件缓存
|
|
78
|
+
# 然后重启 opencode,会自动重新下载最新版
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
插件首次出图时也会查一次 npm,发现落后就在工具输出末尾提示你执行上面这条命令(可用 `updateCheck: false` 关掉)。
|
|
82
|
+
|
|
83
|
+
不想用命令的话,等价于手动删缓存后重启:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
rm -rf ~/.cache/opencode/packages/opencode-image*
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
维护者发布新版:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
git commit -am "..." # npm version 要求工作区干净
|
|
93
|
+
npm run release # = version patch + publish + 清掉本机缓存
|
|
94
|
+
```
|
|
95
|
+
|
|
57
96
|
## 环境变量
|
|
58
97
|
|
|
59
98
|
```bash
|
|
@@ -78,6 +117,8 @@ bun run build # 产出 dist/index.js + dist/index.d.ts
|
|
|
78
117
|
|
|
79
118
|
- 只有能渲染 Markdown 图片的客户端才看得到图;纯终端 TUI 里会退化成一串 URL。
|
|
80
119
|
- 预览图缓存在系统临时目录,`serverIdleMs = 0`(默认)时随进程退出由系统回收。
|
|
120
|
+
- 两个不同项目极少数情况下会派生出同一个端口,此时后启动的实例会报错并提示用 `serverPort` 指定端口。
|
|
121
|
+
- 工具结果里的 `attachments` 只是给模型看的,opencode 客户端**不会**把工具附件渲染成图片,所以内联显示必须靠上面的 Markdown 链接。
|
|
81
122
|
|
|
82
123
|
## License
|
|
83
124
|
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "node:fs"
|
|
3
|
+
import * as os from "node:os"
|
|
4
|
+
import * as path from "node:path"
|
|
5
|
+
|
|
6
|
+
const PACKAGE = "opencode-image"
|
|
7
|
+
|
|
8
|
+
const version = (() => {
|
|
9
|
+
try {
|
|
10
|
+
const file = new URL("../package.json", import.meta.url)
|
|
11
|
+
return JSON.parse(fs.readFileSync(file, "utf8")).version ?? "unknown"
|
|
12
|
+
} catch {
|
|
13
|
+
return "unknown"
|
|
14
|
+
}
|
|
15
|
+
})()
|
|
16
|
+
|
|
17
|
+
const USAGE = `${PACKAGE} v${version}
|
|
18
|
+
|
|
19
|
+
用法:
|
|
20
|
+
npx ${PACKAGE} --update 清理 opencode 的插件缓存,然后重启 opencode 即可装上最新版
|
|
21
|
+
npx ${PACKAGE} --version 显示版本
|
|
22
|
+
npx ${PACKAGE} --help 显示帮助
|
|
23
|
+
|
|
24
|
+
为什么需要 --update:
|
|
25
|
+
opencode 对 npm 形式的插件不做版本检查——只要缓存目录里已经装过就直接复用。
|
|
26
|
+
所以发布新版后必须清掉缓存,opencode 才会重新下载。详见 README「升级」一节。
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
/** opencode 的插件缓存在 <cache>/packages/<spec>,spec 可能是 opencode-image 或 opencode-image@latest */
|
|
30
|
+
function cacheRoot() {
|
|
31
|
+
return process.env.OPENCODE_IMAGE_CACHE_ROOT ?? path.join(os.homedir(), ".cache", "opencode", "packages")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function update() {
|
|
35
|
+
const root = cacheRoot()
|
|
36
|
+
if (!fs.existsSync(root)) {
|
|
37
|
+
console.log(`缓存目录不存在,无需清理:${root}`)
|
|
38
|
+
console.log("直接重启 opencode 即可。")
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const targets = fs
|
|
43
|
+
.readdirSync(root)
|
|
44
|
+
.filter((entry) => entry === PACKAGE || entry.startsWith(`${PACKAGE}@`))
|
|
45
|
+
.map((entry) => path.join(root, entry))
|
|
46
|
+
|
|
47
|
+
if (targets.length === 0) {
|
|
48
|
+
console.log(`没有找到 ${PACKAGE} 的缓存,无需清理。`)
|
|
49
|
+
console.log("直接重启 opencode 即可。")
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const target of targets) {
|
|
54
|
+
fs.rmSync(target, { recursive: true, force: true })
|
|
55
|
+
console.log(`已删除 ${target}`)
|
|
56
|
+
}
|
|
57
|
+
console.log("")
|
|
58
|
+
console.log(`✅ 已清理 ${targets.length} 个缓存目录,重启 opencode 后会重新下载最新版。`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const args = process.argv.slice(2)
|
|
62
|
+
|
|
63
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
64
|
+
console.log(USAGE)
|
|
65
|
+
process.exit(0)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
69
|
+
console.log(version)
|
|
70
|
+
process.exit(0)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (args.includes("--update")) {
|
|
74
|
+
update()
|
|
75
|
+
process.exit(0)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(USAGE)
|
|
79
|
+
process.exit(1)
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { tool } from "@opencode-ai/plugin";
|
|
3
|
-
import * as
|
|
3
|
+
import * as fs4 from "node:fs";
|
|
4
4
|
import * as path3 from "node:path";
|
|
5
5
|
|
|
6
6
|
// src/api.ts
|
|
@@ -137,6 +137,7 @@ var DEFAULT_BASE_URL = "https://pi-ai.roiscloud.com/v1";
|
|
|
137
137
|
var DEFAULT_MODEL = "gpt-image-2";
|
|
138
138
|
var DEFAULT_MAX_SIZE = 1024;
|
|
139
139
|
var DEFAULT_DISPLAY_MAX_SIZE = 448;
|
|
140
|
+
var DEFAULT_SERVER_PORT = 0;
|
|
140
141
|
var DEFAULT_SERVER_IDLE_MS = 0;
|
|
141
142
|
var DEFAULT_TIMEOUT_MS = 600000;
|
|
142
143
|
function resolveOptions(options) {
|
|
@@ -152,17 +153,76 @@ function resolveOptions(options) {
|
|
|
152
153
|
outDir: opts.outDir,
|
|
153
154
|
maxSize: opts.maxSize ?? DEFAULT_MAX_SIZE,
|
|
154
155
|
displayMaxSize: opts.displayMaxSize ?? DEFAULT_DISPLAY_MAX_SIZE,
|
|
156
|
+
serverPort: opts.serverPort ?? DEFAULT_SERVER_PORT,
|
|
155
157
|
serverIdleMs: opts.serverIdleMs ?? DEFAULT_SERVER_IDLE_MS,
|
|
158
|
+
updateCheck: opts.updateCheck ?? true,
|
|
156
159
|
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
157
160
|
};
|
|
158
161
|
}
|
|
159
162
|
|
|
160
|
-
// src/
|
|
163
|
+
// src/update.ts
|
|
161
164
|
import * as fs2 from "node:fs";
|
|
165
|
+
var REGISTRY = "https://registry.npmjs.org";
|
|
166
|
+
var PACKAGE = "opencode-image";
|
|
167
|
+
function parse(version) {
|
|
168
|
+
const [core, pre = ""] = version.split("-", 2);
|
|
169
|
+
const nums = core.split(".").map((part) => Number.parseInt(part, 10));
|
|
170
|
+
return { nums: [nums[0] || 0, nums[1] || 0, nums[2] || 0], pre };
|
|
171
|
+
}
|
|
172
|
+
function isNewer(candidate, current) {
|
|
173
|
+
const a = parse(candidate);
|
|
174
|
+
const b = parse(current);
|
|
175
|
+
for (let i = 0;i < 3; i++) {
|
|
176
|
+
if (a.nums[i] !== b.nums[i])
|
|
177
|
+
return a.nums[i] > b.nums[i];
|
|
178
|
+
}
|
|
179
|
+
if (a.pre === b.pre)
|
|
180
|
+
return false;
|
|
181
|
+
if (!a.pre)
|
|
182
|
+
return true;
|
|
183
|
+
if (!b.pre)
|
|
184
|
+
return false;
|
|
185
|
+
return a.pre > b.pre;
|
|
186
|
+
}
|
|
187
|
+
function readOwnVersion() {
|
|
188
|
+
try {
|
|
189
|
+
const file = new URL("../package.json", import.meta.url);
|
|
190
|
+
const pkg = JSON.parse(fs2.readFileSync(file, "utf8"));
|
|
191
|
+
return pkg.version;
|
|
192
|
+
} catch {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function upgradeHint(latest, current) {
|
|
197
|
+
return `提示:${PACKAGE} 有新版本 ${latest}(当前 ${current})。升级:npx ${PACKAGE} --update 然后重启 opencode。`;
|
|
198
|
+
}
|
|
199
|
+
async function checkForUpdate(current, timeoutMs = 3000) {
|
|
200
|
+
const controller = new AbortController;
|
|
201
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetch(`${REGISTRY}/${PACKAGE}/latest`, {
|
|
204
|
+
signal: controller.signal,
|
|
205
|
+
headers: { accept: "application/json" }
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok)
|
|
208
|
+
return;
|
|
209
|
+
const body = await res.json();
|
|
210
|
+
if (!body.version || !isNewer(body.version, current))
|
|
211
|
+
return;
|
|
212
|
+
return upgradeHint(body.version, current);
|
|
213
|
+
} catch {
|
|
214
|
+
return;
|
|
215
|
+
} finally {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/server.ts
|
|
221
|
+
import * as fs3 from "node:fs";
|
|
162
222
|
import * as http from "node:http";
|
|
163
223
|
import * as os from "node:os";
|
|
164
224
|
import * as path2 from "node:path";
|
|
165
|
-
import { randomUUID } from "node:crypto";
|
|
225
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
166
226
|
var MIME_BY_EXT = {
|
|
167
227
|
".png": "image/png",
|
|
168
228
|
".jpg": "image/jpeg",
|
|
@@ -170,71 +230,114 @@ var MIME_BY_EXT = {
|
|
|
170
230
|
".webp": "image/webp",
|
|
171
231
|
".gif": "image/gif"
|
|
172
232
|
};
|
|
233
|
+
var ID_PATTERN = /^\/img\/([A-Za-z0-9_-]{1,2048})$/;
|
|
234
|
+
var APP_NAME = "opencode-image";
|
|
235
|
+
function derivePort(key) {
|
|
236
|
+
const digest = createHash("sha1").update(key).digest();
|
|
237
|
+
return 41000 + digest.readUInt16BE(0) % 4000;
|
|
238
|
+
}
|
|
173
239
|
|
|
174
240
|
class ImageServer {
|
|
175
241
|
idleMs;
|
|
242
|
+
configuredPort;
|
|
176
243
|
server;
|
|
177
244
|
port = 0;
|
|
178
|
-
|
|
245
|
+
root;
|
|
246
|
+
ready = false;
|
|
247
|
+
owned = false;
|
|
248
|
+
extra = new Map;
|
|
179
249
|
previews = new Map;
|
|
180
250
|
timer;
|
|
181
251
|
starting;
|
|
182
|
-
constructor(idleMs) {
|
|
252
|
+
constructor(idleMs, configuredPort) {
|
|
183
253
|
this.idleMs = idleMs;
|
|
254
|
+
this.configuredPort = configuredPort;
|
|
184
255
|
}
|
|
185
256
|
get baseURL() {
|
|
186
257
|
return this.port ? `http://127.0.0.1:${this.port}` : "";
|
|
187
258
|
}
|
|
188
|
-
|
|
259
|
+
idFor(file) {
|
|
260
|
+
const root = this.root;
|
|
261
|
+
if (root) {
|
|
262
|
+
const relative2 = path2.relative(root, file);
|
|
263
|
+
if (relative2 && !relative2.startsWith("..") && !path2.isAbsolute(relative2)) {
|
|
264
|
+
return Buffer.from(relative2.split(path2.sep).join("/"), "utf8").toString("base64url");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const [id2, registered] of this.extra) {
|
|
268
|
+
if (registered === file)
|
|
269
|
+
return id2;
|
|
270
|
+
}
|
|
189
271
|
const id = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
190
|
-
|
|
191
|
-
this.registry.set(id, { file, mime });
|
|
272
|
+
this.extra.set(id, file);
|
|
192
273
|
return id;
|
|
193
274
|
}
|
|
194
|
-
url(
|
|
195
|
-
return `${this.baseURL}/img/${
|
|
275
|
+
url(file, width) {
|
|
276
|
+
return `${this.baseURL}/img/${this.idFor(file)}?w=${width}`;
|
|
196
277
|
}
|
|
197
|
-
async start() {
|
|
198
|
-
|
|
278
|
+
async start(opts) {
|
|
279
|
+
this.root = path2.resolve(opts.outDir);
|
|
280
|
+
if (this.ready) {
|
|
199
281
|
this.arm();
|
|
200
282
|
return;
|
|
201
283
|
}
|
|
202
284
|
if (!this.starting) {
|
|
203
|
-
this.starting = this.listen().finally(() => {
|
|
285
|
+
this.starting = this.listen(opts).finally(() => {
|
|
204
286
|
this.starting = undefined;
|
|
205
287
|
});
|
|
206
288
|
}
|
|
207
289
|
await this.starting;
|
|
290
|
+
this.ready = true;
|
|
208
291
|
this.arm();
|
|
209
292
|
}
|
|
210
293
|
stop() {
|
|
211
294
|
if (this.timer)
|
|
212
295
|
clearTimeout(this.timer);
|
|
213
296
|
this.timer = undefined;
|
|
214
|
-
|
|
297
|
+
if (this.owned) {
|
|
298
|
+
this.server?.closeAllConnections?.();
|
|
299
|
+
this.server?.close();
|
|
300
|
+
}
|
|
215
301
|
this.server = undefined;
|
|
302
|
+
this.owned = false;
|
|
303
|
+
this.ready = false;
|
|
216
304
|
this.port = 0;
|
|
217
|
-
server?.closeAllConnections?.();
|
|
218
|
-
server?.close();
|
|
219
305
|
for (const file of this.previews.values())
|
|
220
|
-
|
|
306
|
+
fs3.rmSync(file, { force: true });
|
|
221
307
|
this.previews.clear();
|
|
222
308
|
}
|
|
223
|
-
async listen() {
|
|
309
|
+
async listen(opts) {
|
|
310
|
+
const port = this.configuredPort || derivePort(opts.directory);
|
|
224
311
|
const server = http.createServer((req, res) => void this.handle(req, res));
|
|
225
312
|
server.unref();
|
|
226
|
-
await new Promise((
|
|
227
|
-
server.once("error",
|
|
228
|
-
server.listen(
|
|
313
|
+
const failure = await new Promise((resolve2) => {
|
|
314
|
+
server.once("error", resolve2);
|
|
315
|
+
server.listen(port, "127.0.0.1", () => resolve2(undefined));
|
|
229
316
|
});
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
317
|
+
if (!failure) {
|
|
318
|
+
this.server = server;
|
|
319
|
+
this.port = port;
|
|
320
|
+
this.owned = true;
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
server.close();
|
|
324
|
+
if (failure.code === "EADDRINUSE" && await this.servedByUs(port, opts.outDir)) {
|
|
325
|
+
this.port = port;
|
|
326
|
+
this.owned = false;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
throw new Error(`opencode-image: cannot listen on 127.0.0.1:${port} (${String(failure)}). ` + `Set the serverPort option to a free port, or stop whatever is using it.`);
|
|
330
|
+
}
|
|
331
|
+
async servedByUs(port, outDir) {
|
|
332
|
+
try {
|
|
333
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
334
|
+
if (!res.ok)
|
|
335
|
+
return false;
|
|
336
|
+
const body = await res.json();
|
|
337
|
+
return body.app === APP_NAME && body.root === path2.resolve(outDir);
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
235
340
|
}
|
|
236
|
-
this.server = server;
|
|
237
|
-
this.port = port;
|
|
238
341
|
}
|
|
239
342
|
arm() {
|
|
240
343
|
if (this.timer)
|
|
@@ -247,7 +350,7 @@ class ImageServer {
|
|
|
247
350
|
preview(source, max) {
|
|
248
351
|
const key = `${source}@${max}`;
|
|
249
352
|
const cached = this.previews.get(key);
|
|
250
|
-
if (cached &&
|
|
353
|
+
if (cached && fs3.existsSync(cached))
|
|
251
354
|
return Promise.resolve(cached);
|
|
252
355
|
const destination = path2.join(os.tmpdir(), `opencode-image-${process.pid}-${randomUUID()}.png`);
|
|
253
356
|
return renderPreview(source, destination, max).then((file) => {
|
|
@@ -256,24 +359,60 @@ class ImageServer {
|
|
|
256
359
|
return file;
|
|
257
360
|
});
|
|
258
361
|
}
|
|
362
|
+
resolve(id) {
|
|
363
|
+
const registered = this.extra.get(id);
|
|
364
|
+
if (registered)
|
|
365
|
+
return fs3.existsSync(registered) ? registered : undefined;
|
|
366
|
+
const root = this.root;
|
|
367
|
+
if (!root)
|
|
368
|
+
return;
|
|
369
|
+
let relative2;
|
|
370
|
+
try {
|
|
371
|
+
relative2 = Buffer.from(id, "base64url").toString("utf8");
|
|
372
|
+
} catch {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (!relative2 || relative2.includes("\x00") || path2.isAbsolute(relative2))
|
|
376
|
+
return;
|
|
377
|
+
const target = path2.resolve(root, relative2);
|
|
378
|
+
if (target === root || !target.startsWith(root + path2.sep))
|
|
379
|
+
return;
|
|
380
|
+
try {
|
|
381
|
+
if (!fs3.statSync(target).isFile())
|
|
382
|
+
return;
|
|
383
|
+
const real = fs3.realpathSync(target);
|
|
384
|
+
const realRoot = fs3.realpathSync(root);
|
|
385
|
+
if (real !== realRoot && !real.startsWith(realRoot + path2.sep))
|
|
386
|
+
return;
|
|
387
|
+
} catch {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
return target;
|
|
391
|
+
}
|
|
259
392
|
handle(req, res) {
|
|
260
393
|
this.arm();
|
|
261
394
|
const parsed = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
395
|
+
if (parsed.pathname === "/health") {
|
|
396
|
+
const body = JSON.stringify({ app: APP_NAME, root: this.root ?? "" });
|
|
397
|
+
res.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
|
398
|
+
res.end(body);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const id = ID_PATTERN.exec(parsed.pathname)?.[1];
|
|
402
|
+
const file = id ? this.resolve(id) : undefined;
|
|
403
|
+
if (!file) {
|
|
265
404
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
266
405
|
res.end("not found");
|
|
267
406
|
return;
|
|
268
407
|
}
|
|
269
|
-
this.send(
|
|
408
|
+
this.send(file, Number(parsed.searchParams.get("w")), res);
|
|
270
409
|
}
|
|
271
|
-
async send(
|
|
410
|
+
async send(file, width, res) {
|
|
272
411
|
try {
|
|
273
|
-
const target = width > 0 ? await this.preview(
|
|
274
|
-
const body =
|
|
412
|
+
const target = width > 0 ? await this.preview(file, Math.round(width)) : file;
|
|
413
|
+
const body = fs3.readFileSync(target);
|
|
275
414
|
res.writeHead(200, {
|
|
276
|
-
"content-type":
|
|
415
|
+
"content-type": MIME_BY_EXT[path2.extname(target).toLowerCase()] ?? "application/octet-stream",
|
|
277
416
|
"content-length": body.length,
|
|
278
417
|
"cache-control": "private, max-age=86400"
|
|
279
418
|
});
|
|
@@ -287,8 +426,13 @@ class ImageServer {
|
|
|
287
426
|
|
|
288
427
|
// src/index.ts
|
|
289
428
|
var z = tool.schema;
|
|
429
|
+
function resolveOutDir(directory, outDir) {
|
|
430
|
+
if (!outDir)
|
|
431
|
+
return path3.join(directory, ".opencode-images");
|
|
432
|
+
return path3.isAbsolute(outDir) ? outDir : path3.join(directory, outDir);
|
|
433
|
+
}
|
|
290
434
|
function resolveDestination(filename, outDir, ctx) {
|
|
291
|
-
const dir =
|
|
435
|
+
const dir = resolveOutDir(ctx.directory, outDir);
|
|
292
436
|
if (filename) {
|
|
293
437
|
const target = path3.isAbsolute(filename) ? filename : path3.join(dir, filename);
|
|
294
438
|
return `${target.replace(/\.[A-Za-z0-9]+$/, "")}.png`;
|
|
@@ -303,31 +447,44 @@ function asAttachment(file) {
|
|
|
303
447
|
type: "file",
|
|
304
448
|
mime: "image/png",
|
|
305
449
|
filename: path3.basename(file),
|
|
306
|
-
url: `data:image/png;base64,${
|
|
450
|
+
url: `data:image/png;base64,${fs4.readFileSync(file).toString("base64")}`
|
|
307
451
|
}
|
|
308
452
|
];
|
|
309
453
|
}
|
|
310
454
|
var OpenCodeImagePlugin = async (_input, rawOptions) => {
|
|
311
455
|
const options = resolveOptions(rawOptions);
|
|
312
|
-
const server = new ImageServer(options.serverIdleMs);
|
|
313
|
-
const
|
|
456
|
+
const server = new ImageServer(options.serverIdleMs, options.serverPort);
|
|
457
|
+
const selfVersion = readOwnVersion();
|
|
458
|
+
let updateHint;
|
|
459
|
+
let updateChecked = false;
|
|
460
|
+
const upgradeNotice = async () => {
|
|
461
|
+
if (!options.updateCheck || updateChecked || !selfVersion)
|
|
462
|
+
return updateHint;
|
|
463
|
+
updateChecked = true;
|
|
464
|
+
updateHint = await checkForUpdate(selfVersion);
|
|
465
|
+
return updateHint;
|
|
466
|
+
};
|
|
467
|
+
const result = async (label, prompt, file, ctx, usage) => {
|
|
314
468
|
const { width, height } = await imageDimensions(file);
|
|
315
|
-
const id = server.register(file);
|
|
316
469
|
let display;
|
|
317
470
|
try {
|
|
318
|
-
await server.start();
|
|
319
|
-
display = ` });
|
|
472
|
+
display = `})`;
|
|
320
473
|
} catch (error) {
|
|
321
474
|
display = `[opencode-image: local image server unavailable, cannot render inline: ${String(error)}]`;
|
|
322
475
|
}
|
|
476
|
+
const notice = await upgradeNotice();
|
|
477
|
+
const output = [
|
|
478
|
+
display,
|
|
479
|
+
`${label} saved to ${file} (${width}x${height})`,
|
|
480
|
+
`Refine it by calling image_edit with image="${file}" and a prompt describing the change.`,
|
|
481
|
+
`IMPORTANT: copy the Markdown image line above verbatim into your reply text, otherwise the user cannot see the picture.`
|
|
482
|
+
];
|
|
483
|
+
if (notice)
|
|
484
|
+
output.push(notice);
|
|
323
485
|
return {
|
|
324
486
|
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(`
|
|
487
|
+
output: output.join(`
|
|
331
488
|
`),
|
|
332
489
|
metadata: { file, prompt, usage },
|
|
333
490
|
attachments: asAttachment(file)
|
|
@@ -341,14 +498,14 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
|
|
|
341
498
|
const raw = await fetchImageBytes(remote, signal);
|
|
342
499
|
const destination = resolveDestination(args.filename, options.outDir, ctx);
|
|
343
500
|
await persistImage(raw, destination, options.maxSize);
|
|
344
|
-
return result("Image generated", args.prompt, destination, remote.usage);
|
|
501
|
+
return result("Image generated", args.prompt, destination, ctx, remote.usage);
|
|
345
502
|
} finally {
|
|
346
503
|
clear();
|
|
347
504
|
}
|
|
348
505
|
};
|
|
349
506
|
const edit = async (args, ctx) => {
|
|
350
507
|
const source = path3.isAbsolute(args.image) ? args.image : path3.resolve(ctx.directory, args.image);
|
|
351
|
-
if (!
|
|
508
|
+
if (!fs4.existsSync(source))
|
|
352
509
|
throw new Error(`image_edit: source image not found: ${source}`);
|
|
353
510
|
ctx.metadata({ title: `Editing image: ${args.prompt.slice(0, 60)}` });
|
|
354
511
|
const { signal, clear } = withTimeout(ctx.abort, options.timeoutMs);
|
|
@@ -358,7 +515,7 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
|
|
|
358
515
|
const raw = await fetchImageBytes(remote, signal);
|
|
359
516
|
const destination = resolveDestination(args.filename, options.outDir, ctx);
|
|
360
517
|
await persistImage(raw, destination, options.maxSize);
|
|
361
|
-
return result("Image edited", args.prompt, destination, remote.usage);
|
|
518
|
+
return result("Image edited", args.prompt, destination, ctx, remote.usage);
|
|
362
519
|
} finally {
|
|
363
520
|
clear();
|
|
364
521
|
}
|
package/dist/options.d.ts
CHANGED
|
@@ -12,8 +12,12 @@ export type PluginOptions = {
|
|
|
12
12
|
maxSize?: number;
|
|
13
13
|
/** 在对话里展示的图片最长边(像素);只影响展示,不影响落盘原图 */
|
|
14
14
|
displayMaxSize?: number;
|
|
15
|
+
/** 本地图片服务端口;默认 0 = 由项目目录派生(同一项目恒定),需要避开占用时手动指定 */
|
|
16
|
+
serverPort?: number;
|
|
15
17
|
/** 本地图片服务的空闲关闭时间(毫秒),0 表示不主动关闭 */
|
|
16
18
|
serverIdleMs?: number;
|
|
19
|
+
/** 首次出图时查一次 npm 最新版,落后则在工具输出末尾附升级提示。默认开启 */
|
|
20
|
+
updateCheck?: boolean;
|
|
17
21
|
/** 单次图像请求超时(毫秒) */
|
|
18
22
|
timeoutMs?: number;
|
|
19
23
|
};
|
|
@@ -21,6 +25,8 @@ export declare const DEFAULT_BASE_URL = "https://pi-ai.roiscloud.com/v1";
|
|
|
21
25
|
export declare const DEFAULT_MODEL = "gpt-image-2";
|
|
22
26
|
export declare const DEFAULT_MAX_SIZE = 1024;
|
|
23
27
|
export declare const DEFAULT_DISPLAY_MAX_SIZE = 448;
|
|
28
|
+
/** 0 表示由项目目录派生端口,保证同一项目跨重启链接不变 */
|
|
29
|
+
export declare const DEFAULT_SERVER_PORT = 0;
|
|
24
30
|
export declare const DEFAULT_SERVER_IDLE_MS = 0;
|
|
25
31
|
export declare const DEFAULT_TIMEOUT_MS = 600000;
|
|
26
32
|
export type ResolvedOptions = {
|
|
@@ -30,7 +36,9 @@ export type ResolvedOptions = {
|
|
|
30
36
|
outDir?: string;
|
|
31
37
|
maxSize: number;
|
|
32
38
|
displayMaxSize: number;
|
|
39
|
+
serverPort: number;
|
|
33
40
|
serverIdleMs: number;
|
|
41
|
+
updateCheck: boolean;
|
|
34
42
|
timeoutMs: number;
|
|
35
43
|
};
|
|
36
44
|
/** 合并默认值、环境变量与用户配置;缺少 apiKey 时直接抛错 */
|
package/dist/server.d.ts
CHANGED
|
@@ -1,35 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 端口由项目目录派生:同一个项目永远是同一个端口,所以历史消息里的链接跨重启仍然有效;
|
|
3
|
+
* 不同项目基本不会撞到同一个端口。需要覆盖时用 serverPort 配置项。
|
|
4
|
+
*/
|
|
5
|
+
export declare function derivePort(key: string): number;
|
|
1
6
|
/**
|
|
2
7
|
* 本地图片服务:把生成结果通过 http://127.0.0.1:<port>/img/<id>?w=<width> 暴露给客户端渲染。
|
|
3
8
|
*
|
|
4
9
|
* 客户端限制说明:图片只有出现在助手回复的 Markdown 正文里才会渲染,
|
|
5
10
|
* 而正文里的 file:// 会被 Chromium 拦截,所以必须走本地 HTTP。
|
|
6
11
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
12
|
+
* 稳定性:id 是「文件相对 outDir 的路径」做 base64url 编码,可逆且稳定。
|
|
13
|
+
* 因此服务不需要任何内存登记表——同一个文件在任何一次启动里都能解析出同一个 id,
|
|
14
|
+
* 重启后历史消息里的旧链接依然可访问。
|
|
15
|
+
*
|
|
16
|
+
* 安全边界:请求里的 id 只允许解析成 outDir 内部的普通文件,
|
|
17
|
+
* 解析后会做 containment + realpath 双重校验,防路径穿越与符号链接逃逸。
|
|
9
18
|
*/
|
|
10
19
|
export declare class ImageServer {
|
|
11
20
|
private readonly idleMs;
|
|
21
|
+
private readonly configuredPort;
|
|
12
22
|
private server?;
|
|
13
23
|
private port;
|
|
14
|
-
private
|
|
24
|
+
private root?;
|
|
25
|
+
private ready;
|
|
26
|
+
private owned;
|
|
27
|
+
/** outDir 之外的文件退回进程内随机 id(仅本次运行有效) */
|
|
28
|
+
private extra;
|
|
15
29
|
private previews;
|
|
16
30
|
private timer?;
|
|
17
31
|
private starting?;
|
|
18
|
-
constructor(idleMs: number);
|
|
32
|
+
constructor(idleMs: number, configuredPort: number);
|
|
19
33
|
get baseURL(): string;
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
url(
|
|
23
|
-
/**
|
|
24
|
-
start(
|
|
34
|
+
/** 生成稳定 id;outDir 内的文件跨重启一致 */
|
|
35
|
+
idFor(file: string): string;
|
|
36
|
+
url(file: string, width: number): string;
|
|
37
|
+
/** 懒启动:首次需要时按派生端口监听;unref 后不阻塞宿主进程退出 */
|
|
38
|
+
start(opts: {
|
|
39
|
+
directory: string;
|
|
40
|
+
outDir: string;
|
|
41
|
+
}): Promise<void>;
|
|
25
42
|
/**
|
|
26
43
|
* 关闭服务并清理预览缓存。
|
|
27
44
|
* 默认不调用:历史消息里的图片会重新请求,提前关闭会 404。
|
|
28
45
|
*/
|
|
29
46
|
stop(): void;
|
|
30
47
|
private listen;
|
|
48
|
+
private servedByUs;
|
|
31
49
|
private arm;
|
|
32
50
|
private preview;
|
|
51
|
+
/** id -> 文件;只接受 outDir 内、真实存在、且 realpath 不逃逸的普通文件 */
|
|
52
|
+
private resolve;
|
|
33
53
|
private handle;
|
|
34
54
|
private send;
|
|
35
55
|
}
|
package/dist/update.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const REGISTRY = "https://registry.npmjs.org";
|
|
2
|
+
export declare const PACKAGE = "opencode-image";
|
|
3
|
+
/** 只做 x.y.z + 预发布标识的比较,够用且不引依赖 */
|
|
4
|
+
export declare function isNewer(candidate: string, current: string): boolean;
|
|
5
|
+
/** 从包自身的 package.json 读版本;dist/index.js 与 src/index.ts 都能解析到 */
|
|
6
|
+
export declare function readOwnVersion(): string | undefined;
|
|
7
|
+
export declare function upgradeHint(latest: string, current: string): string;
|
|
8
|
+
/** 查 npm 最新版;落后则返回提示行,否则返回 undefined。失败一律当作"没查到"。 */
|
|
9
|
+
export declare function checkForUpdate(current: string, timeoutMs?: number): Promise<string | undefined>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-image",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Image generation and editing tools for opencode, with inline rendering in the conversation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|
|
36
|
+
"bin",
|
|
36
37
|
"README.md",
|
|
37
38
|
"LICENSE"
|
|
38
39
|
],
|
|
@@ -42,7 +43,8 @@
|
|
|
42
43
|
"build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
|
|
43
44
|
"typecheck": "tsc --noEmit",
|
|
44
45
|
"test": "bun test",
|
|
45
|
-
"prepublishOnly": "bun run build && bun test"
|
|
46
|
+
"prepublishOnly": "bun run build && bun test",
|
|
47
|
+
"release": "npm version patch && npm publish && rm -rf ~/.cache/opencode/packages/opencode-image*"
|
|
46
48
|
},
|
|
47
49
|
"engines": {
|
|
48
50
|
"node": ">=18"
|
|
@@ -54,5 +56,8 @@
|
|
|
54
56
|
"devDependencies": {
|
|
55
57
|
"@types/node": "^24.12.2",
|
|
56
58
|
"typescript": "^5.8.2"
|
|
59
|
+
},
|
|
60
|
+
"bin": {
|
|
61
|
+
"opencode-image": "./bin/cli.js"
|
|
57
62
|
}
|
|
58
63
|
}
|