opencode-image 0.1.0 → 0.1.2
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 +103 -6
- package/bin/cli.js +129 -0
- package/dist/cache.d.ts +44 -0
- package/dist/index.js +427 -81
- package/dist/options.d.ts +30 -3
- package/dist/server.d.ts +43 -10
- package/dist/update.d.ts +9 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -31,10 +31,14 @@ opencode 启动时会自动从 npm 安装插件到 `~/.cache/opencode/packages/o
|
|
|
31
31
|
| `apiKey` | `PI_IMAGE_API_KEY` | **必填**。接口密钥,建议用环境变量而不是写进配置 |
|
|
32
32
|
| `baseURL` | `PI_IMAGE_BASE_URL` 或内置默认 | OpenAI 兼容接口地址 |
|
|
33
33
|
| `model` | `gpt-image-2` | 模型名 |
|
|
34
|
-
| `outDir` |
|
|
34
|
+
| `outDir` | `<数据目录>/opencode-image` | 图片输出目录。默认全局,与项目/会话解耦;填相对路径(如 `.opencode-images`)则落到当前项目 |
|
|
35
35
|
| `maxSize` | `1024` | 落盘图片最长边上限,超出等比缩小 |
|
|
36
|
-
| `displayMaxSize` | `
|
|
36
|
+
| `displayMaxSize` | `0` | 对话里展示的最长边。`0` = 不缩放,**直接显示原图**(右键复制/另存拿到的就是原图) |
|
|
37
|
+
| `serverPort` | `0` | 本地图片服务端口。`0` = 由项目目录派生(同一项目恒定,保证历史链接跨重启有效) |
|
|
37
38
|
| `serverIdleMs` | `0` | 本地图片服务空闲关闭时间(毫秒),`0` = 不主动关闭 |
|
|
39
|
+
| `updateCheck` | `true` | 首次出图时查一次 npm 最新版,落后则在工具输出末尾附升级提示 |
|
|
40
|
+
| `cacheDir` | `<系统临时目录>/opencode-image` | 对话里展示用的缩略图缓存目录 |
|
|
41
|
+
| `cacheLimit` | `50` | 缩略图缓存最多保留多少个文件,超出按最久未访问淘汰;`0` = 不限制 |
|
|
38
42
|
| `timeoutMs` | `600000` | 单次请求超时 |
|
|
39
43
|
|
|
40
44
|
## 图片是怎么显示出来的
|
|
@@ -42,18 +46,109 @@ opencode 启动时会自动从 npm 安装插件到 `~/.cache/opencode/packages/o
|
|
|
42
46
|
openCode(桌面端 / Web)**只在助手回复的 Markdown 正文里渲染图片**,工具输出一律按纯文本处理。
|
|
43
47
|
而正文里的 `file://` 会被 Chromium 拦掉,所以插件做了这件事:
|
|
44
48
|
|
|
45
|
-
1. 图片落盘到 `outDir
|
|
46
|
-
2. 插件进程内起一个只绑 `127.0.0.1` 的 HTTP
|
|
47
|
-
3.
|
|
49
|
+
1. 图片落盘到 `outDir`(默认是全局数据目录,不在项目里)
|
|
50
|
+
2. 插件进程内起一个只绑 `127.0.0.1` 的 HTTP 服务
|
|
51
|
+
3. 工具输出给出 ``(`displayMaxSize = 0` 时就是原图)
|
|
48
52
|
4. 工具描述里强制模型把这行**原样抄进回复正文**
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
> **不支持「图片套链接」**:opencode 覆写了 marked 的 link renderer,取的是链接里的原始文本,
|
|
55
|
+
> `[](原图)` 里的图片不会被渲染。所以缩略图模式下改成两行:
|
|
56
|
+
> `` + `[查看原图](原图)`。
|
|
57
|
+
>
|
|
58
|
+
> 显示原图还是缩略图只取决于 `displayMaxSize`:
|
|
59
|
+
> - `0`(默认)→ 直接显示原图,会话里右键 Copy / Save 拿到的就是原图
|
|
60
|
+
> - `> 0`(如 `448`)→ 会话里显示轻量缩略图,下面附带一个「查看原图」链接,
|
|
61
|
+
> 点击会用系统浏览器打开原图(opencode 把所有外链交给系统浏览器)
|
|
62
|
+
|
|
63
|
+
**链接为什么跨重启还有效**:`id` 是「文件相对 `outDir` 的路径」的 base64url 编码,可逆且稳定;
|
|
64
|
+
端口默认由项目目录派生(同一项目恒定)。所以服务**不需要任何内存登记表**,
|
|
65
|
+
重启后历史消息里的旧链接仍能解析到同一个文件。
|
|
66
|
+
|
|
67
|
+
服务的安全边界:请求里的 `id` 只允许解析成 `outDir` 内部的普通文件,
|
|
68
|
+
解析后做 containment + `realpath` 双重校验,防路径穿越与符号链接逃逸。
|
|
69
|
+
`outDir` 之外的文件只能用进程内临时 id(不跨重启)。
|
|
51
70
|
|
|
52
71
|
服务的生命周期:**首次生成图片时懒启动**,端口随机;`unref()` 后不阻塞 opencode 退出,
|
|
53
72
|
opencode 进程结束即自动释放。默认不主动关闭——历史消息里的图片会被重新请求,提前关闭会 404。
|
|
54
73
|
|
|
55
74
|
> 同时在工具结果里返回了 `attachments`,这样模型自己也能看到生成的图,便于继续迭代。
|
|
56
75
|
|
|
76
|
+
## 升级
|
|
77
|
+
|
|
78
|
+
opencode 对 npm 形式的插件**不会自动升级**。
|
|
79
|
+
|
|
80
|
+
它的安装逻辑是 `Npm.add()`:只要缓存目录里已经有这个包就直接复用,不会去比对版本
|
|
81
|
+
(opencode 源码 `packages/core/src/npm.ts` 里的短路分支)。而裸包名会被规范化成
|
|
82
|
+
`opencode-image@latest`,缓存目录名固定为 `~/.cache/opencode/packages/opencode-image@latest`,
|
|
83
|
+
所以你会一直停留在**第一次安装到的版本**。
|
|
84
|
+
|
|
85
|
+
升级只需要两条:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
npx opencode-image --update # 清理 opencode 的插件缓存
|
|
89
|
+
# 然后重启 opencode,会自动重新下载最新版
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
插件首次出图时也会查一次 npm,发现落后就在工具输出末尾提示你执行上面这条命令(可用 `updateCheck: false` 关掉)。
|
|
93
|
+
|
|
94
|
+
不想用命令的话,等价于手动删缓存后重启:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
rm -rf ~/.cache/opencode/packages/opencode-image*
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
维护者发布新版:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
git commit -am "..." # npm version 要求工作区干净
|
|
104
|
+
npm run release # = version patch + publish + 清掉本机缓存
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## 图片放在哪
|
|
108
|
+
|
|
109
|
+
默认落在 **opencode 的数据目录**(`<XDG_DATA_HOME>/opencode/opencode-image/`,macOS/Linux 即
|
|
110
|
+
`~/.local/share/opencode/opencode-image/`),跟 `opencode.db` 同级。
|
|
111
|
+
|
|
112
|
+
这样设计的原因:
|
|
113
|
+
|
|
114
|
+
- **不与项目/会话绑定**:换目录打开老会话、删掉项目、移动仓库,历史里的图都还在
|
|
115
|
+
- **不污染工作区**:不再出现在 git 变更里
|
|
116
|
+
- **全机一个服务**:根目录固定 → 端口由它派生 → 所有会话共用一个本地服务,而不是每个项目一个
|
|
117
|
+
|
|
118
|
+
只有当你确实要让图片成为项目资产时,才把 `outDir` 设成项目内路径(如 `".opencode-images"`),
|
|
119
|
+
或者让 agent 把图复制进仓库 —— 这与 Codex 的做法一致(Codex 默认存 `$CODEX_HOME/generated_images/`,
|
|
120
|
+
需要在项目里用时再复制过去)。
|
|
121
|
+
|
|
122
|
+
## 缓存与清理
|
|
123
|
+
|
|
124
|
+
有两处"缓存",职责完全不同,别搞混:
|
|
125
|
+
|
|
126
|
+
| 是什么 | 在哪 | 删了会怎样 |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| **生成的图片本体** | `<数据目录>/opencode-image/`(`outDir` 可配;macOS/Linux 默认 `~/.local/share/opencode/opencode-image/`) | **会话里的图片会 404**。服务按路径解析,源文件没了就找不到 |
|
|
129
|
+
| **对话缩略图**(448px) | `<系统临时目录>/opencode-image/`(`cacheDir` 可配) | 无影响,下次打开会按需重新生成 |
|
|
130
|
+
|
|
131
|
+
缩略图缓存文件名由「源文件路径 + 大小 + mtime + 目标宽度」哈希而成,所以同一张图不会重复生成,跨重启也能复用。
|
|
132
|
+
超过 `cacheLimit`(默认 50 个)时按最久未访问淘汰,并在**下一次工具输出末尾**提醒你(含目录、数量、总大小、最旧一个存在了多久)——每进程只提醒一次,不刷屏。
|
|
133
|
+
|
|
134
|
+
清理缩略图缓存(两种方式等价):
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
npx opencode-image --clean-cache
|
|
138
|
+
# 如果你改过 cacheDir:
|
|
139
|
+
npx opencode-image --clean-cache --cache-dir /your/cache/dir
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
也可以直接在会话里让 agent 调用 `image_cache`:
|
|
143
|
+
|
|
144
|
+
- `image_cache(action="status")` —— 目录、数量、总大小、最旧一个多久了
|
|
145
|
+
- `image_cache(action="clean")` —— 清空缩略图缓存(不动 `outDir` 里的原图)
|
|
146
|
+
|
|
147
|
+
> **建议**:把输出目录加进项目 `.gitignore`,否则每画一张图都会出现在 git 变更里:
|
|
148
|
+
> ```
|
|
149
|
+
> .opencode-images/
|
|
150
|
+
> ```
|
|
151
|
+
|
|
57
152
|
## 环境变量
|
|
58
153
|
|
|
59
154
|
```bash
|
|
@@ -78,6 +173,8 @@ bun run build # 产出 dist/index.js + dist/index.d.ts
|
|
|
78
173
|
|
|
79
174
|
- 只有能渲染 Markdown 图片的客户端才看得到图;纯终端 TUI 里会退化成一串 URL。
|
|
80
175
|
- 预览图缓存在系统临时目录,`serverIdleMs = 0`(默认)时随进程退出由系统回收。
|
|
176
|
+
- 两个不同项目极少数情况下会派生出同一个端口,此时后启动的实例会报错并提示用 `serverPort` 指定端口。
|
|
177
|
+
- 工具结果里的 `attachments` 只是给模型看的,opencode 客户端**不会**把工具附件渲染成图片,所以内联显示必须靠上面的 Markdown 链接。
|
|
81
178
|
|
|
82
179
|
## License
|
|
83
180
|
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), "opencode-image")
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2)
|
|
10
|
+
|
|
11
|
+
const version = (() => {
|
|
12
|
+
try {
|
|
13
|
+
const file = new URL("../package.json", import.meta.url)
|
|
14
|
+
return JSON.parse(fs.readFileSync(file, "utf8")).version ?? "unknown"
|
|
15
|
+
} catch {
|
|
16
|
+
return "unknown"
|
|
17
|
+
}
|
|
18
|
+
})()
|
|
19
|
+
|
|
20
|
+
const USAGE = `${PACKAGE} v${version}
|
|
21
|
+
|
|
22
|
+
用法:
|
|
23
|
+
npx ${PACKAGE} --update 清理 opencode 的插件缓存并重启 opencode,即可装上最新版
|
|
24
|
+
npx ${PACKAGE} --clean-cache 清理对话缩略图缓存(不影响生成的图片)
|
|
25
|
+
npx ${PACKAGE} --version 显示版本
|
|
26
|
+
npx ${PACKAGE} --help 显示帮助
|
|
27
|
+
|
|
28
|
+
选项:
|
|
29
|
+
--cache-dir <path> 缩略图缓存目录,默认 <系统临时目录>/opencode-image
|
|
30
|
+
(如果你的配置里改过 cacheDir,用这个参数或 OPENCODE_IMAGE_CACHE_DIR 指过去)
|
|
31
|
+
|
|
32
|
+
为什么需要 --update:
|
|
33
|
+
opencode 对 npm 形式的插件不做版本检查——只要缓存目录里已经装过就直接复用。
|
|
34
|
+
所以发布新版后必须清掉缓存,opencode 才会重新下载。详见 README「升级」一节。
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
function flag(name) {
|
|
38
|
+
const index = args.indexOf(name)
|
|
39
|
+
return index >= 0 ? (args[index + 1] ?? "") : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function humanBytes(bytes) {
|
|
43
|
+
if (bytes < 1024) return `${bytes} B`
|
|
44
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
|
45
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** opencode 的插件缓存在 <cache>/packages/<spec>,spec 可能是 opencode-image 或 opencode-image@latest */
|
|
49
|
+
function pluginCacheRoot() {
|
|
50
|
+
return process.env.OPENCODE_IMAGE_PLUGIN_CACHE ?? path.join(os.homedir(), ".cache", "opencode", "packages")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function update() {
|
|
54
|
+
const root = pluginCacheRoot()
|
|
55
|
+
if (!fs.existsSync(root)) {
|
|
56
|
+
console.log(`缓存目录不存在,无需清理:${root}`)
|
|
57
|
+
console.log("直接重启 opencode 即可。")
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const targets = fs
|
|
62
|
+
.readdirSync(root)
|
|
63
|
+
.filter((entry) => entry === PACKAGE || entry.startsWith(`${PACKAGE}@`))
|
|
64
|
+
.map((entry) => path.join(root, entry))
|
|
65
|
+
|
|
66
|
+
if (targets.length === 0) {
|
|
67
|
+
console.log(`没有找到 ${PACKAGE} 的缓存,无需清理。`)
|
|
68
|
+
console.log("直接重启 opencode 即可。")
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const target of targets) {
|
|
73
|
+
fs.rmSync(target, { recursive: true, force: true })
|
|
74
|
+
console.log(`已删除 ${target}`)
|
|
75
|
+
}
|
|
76
|
+
console.log("")
|
|
77
|
+
console.log(`✅ 已清理 ${targets.length} 个缓存目录,重启 opencode 后会重新下载最新版。`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cleanCache() {
|
|
81
|
+
const dir = flag("--cache-dir") ?? process.env.OPENCODE_IMAGE_CACHE_DIR ?? DEFAULT_CACHE_DIR
|
|
82
|
+
if (!fs.existsSync(dir)) {
|
|
83
|
+
console.log(`缩略图缓存目录不存在,无需清理:${dir}`)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let count = 0
|
|
88
|
+
let bytes = 0
|
|
89
|
+
for (const name of fs.readdirSync(dir)) {
|
|
90
|
+
if (!name.endsWith(".png")) continue
|
|
91
|
+
const file = path.join(dir, name)
|
|
92
|
+
try {
|
|
93
|
+
const stat = fs.statSync(file)
|
|
94
|
+
if (!stat.isFile()) continue
|
|
95
|
+
bytes += stat.size
|
|
96
|
+
count += 1
|
|
97
|
+
fs.rmSync(file, { force: true })
|
|
98
|
+
} catch {
|
|
99
|
+
// 单个文件删不掉就跳过,最后按实际删掉的数量汇报
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(`✅ 已清理缩略图缓存:${count} 个文件 / ${humanBytes(bytes)}`)
|
|
104
|
+
console.log(`目录:${dir}`)
|
|
105
|
+
console.log("生成的图片不受影响,会话里的图片仍能显示(需要时会重新生成缩略图)。")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
109
|
+
console.log(USAGE)
|
|
110
|
+
process.exit(0)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
114
|
+
console.log(version)
|
|
115
|
+
process.exit(0)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (args.includes("--update")) {
|
|
119
|
+
update()
|
|
120
|
+
process.exit(0)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (args.includes("--clean-cache")) {
|
|
124
|
+
cleanCache()
|
|
125
|
+
process.exit(0)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(USAGE)
|
|
129
|
+
process.exit(1)
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export declare const DEFAULT_CACHE_DIR: string;
|
|
2
|
+
export declare const DEFAULT_CACHE_LIMIT = 50;
|
|
3
|
+
export type CacheStats = {
|
|
4
|
+
dir: string;
|
|
5
|
+
count: number;
|
|
6
|
+
bytes: number;
|
|
7
|
+
/** 最旧一个预览文件已经存在了多久(毫秒);空缓存时为 undefined */
|
|
8
|
+
oldestMs?: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function humanBytes(bytes: number): string;
|
|
11
|
+
export declare function humanAge(ms: number): string;
|
|
12
|
+
/**
|
|
13
|
+
* 对话里展示用的缩略图缓存。
|
|
14
|
+
*
|
|
15
|
+
* 文件名由「源文件绝对路径 + 文件大小 + mtime + 目标宽度」哈希而成,所以:
|
|
16
|
+
* - 同一个文件同一尺寸永远命中同一个文件名 → 不重复生成,跨重启也能复用
|
|
17
|
+
* - 源文件内容变了(mtime/size 变)就是新 key → 不会拿到过期缩略图
|
|
18
|
+
* - 缓存目录里只有我们自己的 *.png,清理时不会误伤其它文件
|
|
19
|
+
*
|
|
20
|
+
* 超过上限时按访问时间(mtime,命中时会 utimes 刷新)淘汰最旧的,并记一条待展示的通知。
|
|
21
|
+
*/
|
|
22
|
+
export declare class PreviewCache {
|
|
23
|
+
private readonly dir;
|
|
24
|
+
private readonly limit;
|
|
25
|
+
private notice?;
|
|
26
|
+
constructor(dir: string, limit: number);
|
|
27
|
+
get directory(): string;
|
|
28
|
+
private target;
|
|
29
|
+
/** 命中则刷新访问时间并返回缓存文件;未命中返回 undefined */
|
|
30
|
+
hit(source: string, max: number): string | undefined;
|
|
31
|
+
/** 收下一个新生成的预览文件(从临时位置挪进缓存),并按需淘汰 */
|
|
32
|
+
put(source: string, max: number, generated: string): string;
|
|
33
|
+
stats(): CacheStats;
|
|
34
|
+
/** 一次性取走待展示的溢出通知 */
|
|
35
|
+
consumeNotice(): string | undefined;
|
|
36
|
+
/** 清空缓存目录里由本插件生成的预览文件 */
|
|
37
|
+
clean(): {
|
|
38
|
+
dir: string;
|
|
39
|
+
count: number;
|
|
40
|
+
bytes: number;
|
|
41
|
+
};
|
|
42
|
+
private entries;
|
|
43
|
+
private enforce;
|
|
44
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { tool } from "@opencode-ai/plugin";
|
|
3
|
-
import * as
|
|
4
|
-
import * as
|
|
3
|
+
import * as fs5 from "node:fs";
|
|
4
|
+
import * as path5 from "node:path";
|
|
5
5
|
|
|
6
6
|
// src/api.ts
|
|
7
7
|
function parseImageResponse(body) {
|
|
@@ -88,9 +88,137 @@ async function editImage(config, source, filename, prompt, signal) {
|
|
|
88
88
|
return readImage(res, "POST /images/edits");
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// src/
|
|
91
|
+
// src/cache.ts
|
|
92
92
|
import * as fs from "node:fs";
|
|
93
|
+
import * as os from "node:os";
|
|
93
94
|
import * as path from "node:path";
|
|
95
|
+
import { createHash } from "node:crypto";
|
|
96
|
+
var DEFAULT_CACHE_DIR = path.join(os.tmpdir(), "opencode-image");
|
|
97
|
+
var DEFAULT_CACHE_LIMIT = 50;
|
|
98
|
+
function humanBytes(bytes) {
|
|
99
|
+
if (bytes < 1024)
|
|
100
|
+
return `${bytes} B`;
|
|
101
|
+
if (bytes < 1024 * 1024)
|
|
102
|
+
return `${(bytes / 1024).toFixed(0)} KB`;
|
|
103
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
104
|
+
}
|
|
105
|
+
function humanAge(ms) {
|
|
106
|
+
const minutes = Math.floor(ms / 60000);
|
|
107
|
+
if (minutes < 1)
|
|
108
|
+
return "不到 1 分钟";
|
|
109
|
+
if (minutes < 60)
|
|
110
|
+
return `${minutes} 分钟`;
|
|
111
|
+
const hours = Math.floor(minutes / 60);
|
|
112
|
+
if (hours < 24)
|
|
113
|
+
return `${hours} 小时`;
|
|
114
|
+
return `${Math.floor(hours / 24)} 天`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
class PreviewCache {
|
|
118
|
+
dir;
|
|
119
|
+
limit;
|
|
120
|
+
notice;
|
|
121
|
+
constructor(dir, limit) {
|
|
122
|
+
this.dir = dir;
|
|
123
|
+
this.limit = limit;
|
|
124
|
+
}
|
|
125
|
+
get directory() {
|
|
126
|
+
return this.dir;
|
|
127
|
+
}
|
|
128
|
+
target(source, max) {
|
|
129
|
+
let stat;
|
|
130
|
+
try {
|
|
131
|
+
stat = fs.statSync(source);
|
|
132
|
+
} catch {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const hash = createHash("sha1").update(`${path.resolve(source)}|${stat.size}|${Math.floor(stat.mtimeMs)}|${max}`).digest("hex").slice(0, 16);
|
|
136
|
+
return path.join(this.dir, `${hash}.png`);
|
|
137
|
+
}
|
|
138
|
+
hit(source, max) {
|
|
139
|
+
const file = this.target(source, max);
|
|
140
|
+
if (!file || !fs.existsSync(file))
|
|
141
|
+
return;
|
|
142
|
+
const now = new Date;
|
|
143
|
+
try {
|
|
144
|
+
fs.utimesSync(file, now, now);
|
|
145
|
+
} catch {}
|
|
146
|
+
return file;
|
|
147
|
+
}
|
|
148
|
+
put(source, max, generated) {
|
|
149
|
+
const file = this.target(source, max);
|
|
150
|
+
if (!file)
|
|
151
|
+
return generated;
|
|
152
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
153
|
+
if (path.resolve(file) !== path.resolve(generated))
|
|
154
|
+
fs.renameSync(generated, file);
|
|
155
|
+
const now = new Date;
|
|
156
|
+
fs.utimesSync(file, now, now);
|
|
157
|
+
this.enforce();
|
|
158
|
+
return file;
|
|
159
|
+
}
|
|
160
|
+
stats() {
|
|
161
|
+
const entries = this.entries();
|
|
162
|
+
const bytes = entries.reduce((sum, entry) => sum + entry.size, 0);
|
|
163
|
+
if (entries.length === 0)
|
|
164
|
+
return { dir: this.dir, count: 0, bytes: 0 };
|
|
165
|
+
const oldest = Math.min(...entries.map((entry) => entry.mtimeMs));
|
|
166
|
+
return { dir: this.dir, count: entries.length, bytes, oldestMs: Date.now() - oldest };
|
|
167
|
+
}
|
|
168
|
+
consumeNotice() {
|
|
169
|
+
const notice = this.notice;
|
|
170
|
+
this.notice = undefined;
|
|
171
|
+
return notice;
|
|
172
|
+
}
|
|
173
|
+
clean() {
|
|
174
|
+
const entries = this.entries();
|
|
175
|
+
const bytes = entries.reduce((sum, entry) => sum + entry.size, 0);
|
|
176
|
+
for (const entry of entries)
|
|
177
|
+
fs.rmSync(entry.file, { force: true });
|
|
178
|
+
this.notice = undefined;
|
|
179
|
+
return { dir: this.dir, count: entries.length, bytes };
|
|
180
|
+
}
|
|
181
|
+
entries() {
|
|
182
|
+
try {
|
|
183
|
+
return fs.readdirSync(this.dir).filter((name) => name.endsWith(".png")).flatMap((name) => {
|
|
184
|
+
const file = path.join(this.dir, name);
|
|
185
|
+
try {
|
|
186
|
+
const stat = fs.statSync(file);
|
|
187
|
+
return stat.isFile() ? [{ file, size: stat.size, mtimeMs: stat.mtimeMs }] : [];
|
|
188
|
+
} catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
enforce() {
|
|
197
|
+
if (this.limit <= 0)
|
|
198
|
+
return;
|
|
199
|
+
const entries = this.entries();
|
|
200
|
+
if (entries.length <= this.limit)
|
|
201
|
+
return;
|
|
202
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
203
|
+
for (const entry of entries.slice(0, entries.length - this.limit)) {
|
|
204
|
+
fs.rmSync(entry.file, { force: true });
|
|
205
|
+
}
|
|
206
|
+
if (this.notice)
|
|
207
|
+
return;
|
|
208
|
+
const stats = this.stats();
|
|
209
|
+
this.notice = [
|
|
210
|
+
`[请转述给用户] 图片预览缓存已达上限(${this.limit} 个),已自动清理最旧的。`,
|
|
211
|
+
`目录:${stats.dir}`,
|
|
212
|
+
`当前:${stats.count} 个 / ${humanBytes(stats.bytes)}` + (stats.oldestMs === undefined ? "" : `,最旧一个存在了 ${humanAge(stats.oldestMs)}`),
|
|
213
|
+
`彻底清理:让 agent 调用 image_cache(action="clean"),或运行 npx opencode-image --clean-cache`
|
|
214
|
+
].join(`
|
|
215
|
+
`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/image.ts
|
|
220
|
+
import * as fs2 from "node:fs";
|
|
221
|
+
import * as path2 from "node:path";
|
|
94
222
|
import sharp from "sharp";
|
|
95
223
|
function fitInside(max) {
|
|
96
224
|
return { width: max, height: max, fit: "inside", withoutEnlargement: true };
|
|
@@ -115,9 +243,9 @@ async function renderPreview(source, destination, max) {
|
|
|
115
243
|
return destination;
|
|
116
244
|
}
|
|
117
245
|
async function persistImage(raw, destination, maxSize) {
|
|
118
|
-
|
|
246
|
+
fs2.mkdirSync(path2.dirname(destination), { recursive: true });
|
|
119
247
|
const staging = `${destination}.${process.pid}-${Date.now()}.download`;
|
|
120
|
-
|
|
248
|
+
fs2.writeFileSync(staging, raw);
|
|
121
249
|
try {
|
|
122
250
|
const meta = await sharp(staging).metadata();
|
|
123
251
|
const width = meta.width ?? 0;
|
|
@@ -127,16 +255,28 @@ async function persistImage(raw, destination, maxSize) {
|
|
|
127
255
|
const pipeline = Math.max(width, height) > maxSize ? sharp(staging).resize(fitInside(maxSize)) : sharp(staging);
|
|
128
256
|
await pipeline.png().toFile(destination);
|
|
129
257
|
} finally {
|
|
130
|
-
|
|
258
|
+
fs2.rmSync(staging, { force: true });
|
|
131
259
|
}
|
|
132
260
|
return imageDimensions(destination);
|
|
133
261
|
}
|
|
134
262
|
|
|
135
263
|
// src/options.ts
|
|
264
|
+
import * as os2 from "node:os";
|
|
265
|
+
import * as path3 from "node:path";
|
|
266
|
+
function defaultImageDir(env = process.env, home = os2.homedir()) {
|
|
267
|
+
const base = env.XDG_DATA_HOME?.trim() || path3.join(home, ".local", "share");
|
|
268
|
+
return path3.join(base, "opencode", "opencode-image");
|
|
269
|
+
}
|
|
270
|
+
function resolveOutDir(directory, outDir) {
|
|
271
|
+
if (!outDir)
|
|
272
|
+
return defaultImageDir();
|
|
273
|
+
return path3.isAbsolute(outDir) ? outDir : path3.join(directory, outDir);
|
|
274
|
+
}
|
|
136
275
|
var DEFAULT_BASE_URL = "https://pi-ai.roiscloud.com/v1";
|
|
137
276
|
var DEFAULT_MODEL = "gpt-image-2";
|
|
138
277
|
var DEFAULT_MAX_SIZE = 1024;
|
|
139
|
-
var DEFAULT_DISPLAY_MAX_SIZE =
|
|
278
|
+
var DEFAULT_DISPLAY_MAX_SIZE = 0;
|
|
279
|
+
var DEFAULT_SERVER_PORT = 0;
|
|
140
280
|
var DEFAULT_SERVER_IDLE_MS = 0;
|
|
141
281
|
var DEFAULT_TIMEOUT_MS = 600000;
|
|
142
282
|
function resolveOptions(options) {
|
|
@@ -152,17 +292,78 @@ function resolveOptions(options) {
|
|
|
152
292
|
outDir: opts.outDir,
|
|
153
293
|
maxSize: opts.maxSize ?? DEFAULT_MAX_SIZE,
|
|
154
294
|
displayMaxSize: opts.displayMaxSize ?? DEFAULT_DISPLAY_MAX_SIZE,
|
|
295
|
+
serverPort: opts.serverPort ?? DEFAULT_SERVER_PORT,
|
|
155
296
|
serverIdleMs: opts.serverIdleMs ?? DEFAULT_SERVER_IDLE_MS,
|
|
297
|
+
updateCheck: opts.updateCheck ?? true,
|
|
298
|
+
cacheDir: opts.cacheDir ?? DEFAULT_CACHE_DIR,
|
|
299
|
+
cacheLimit: opts.cacheLimit ?? DEFAULT_CACHE_LIMIT,
|
|
156
300
|
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
157
301
|
};
|
|
158
302
|
}
|
|
159
303
|
|
|
304
|
+
// src/update.ts
|
|
305
|
+
import * as fs3 from "node:fs";
|
|
306
|
+
var REGISTRY = "https://registry.npmjs.org";
|
|
307
|
+
var PACKAGE = "opencode-image";
|
|
308
|
+
function parse(version) {
|
|
309
|
+
const [core, pre = ""] = version.split("-", 2);
|
|
310
|
+
const nums = core.split(".").map((part) => Number.parseInt(part, 10));
|
|
311
|
+
return { nums: [nums[0] || 0, nums[1] || 0, nums[2] || 0], pre };
|
|
312
|
+
}
|
|
313
|
+
function isNewer(candidate, current) {
|
|
314
|
+
const a = parse(candidate);
|
|
315
|
+
const b = parse(current);
|
|
316
|
+
for (let i = 0;i < 3; i++) {
|
|
317
|
+
if (a.nums[i] !== b.nums[i])
|
|
318
|
+
return a.nums[i] > b.nums[i];
|
|
319
|
+
}
|
|
320
|
+
if (a.pre === b.pre)
|
|
321
|
+
return false;
|
|
322
|
+
if (!a.pre)
|
|
323
|
+
return true;
|
|
324
|
+
if (!b.pre)
|
|
325
|
+
return false;
|
|
326
|
+
return a.pre > b.pre;
|
|
327
|
+
}
|
|
328
|
+
function readOwnVersion() {
|
|
329
|
+
try {
|
|
330
|
+
const file = new URL("../package.json", import.meta.url);
|
|
331
|
+
const pkg = JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
332
|
+
return pkg.version;
|
|
333
|
+
} catch {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function upgradeHint(latest, current) {
|
|
338
|
+
return `[请转述给用户] ${PACKAGE} 有新版本 ${latest}(当前 ${current})。升级:npx ${PACKAGE} --update 然后重启 opencode。`;
|
|
339
|
+
}
|
|
340
|
+
async function checkForUpdate(current, timeoutMs = 3000) {
|
|
341
|
+
const controller = new AbortController;
|
|
342
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
343
|
+
try {
|
|
344
|
+
const res = await fetch(`${REGISTRY}/${PACKAGE}/latest`, {
|
|
345
|
+
signal: controller.signal,
|
|
346
|
+
headers: { accept: "application/json" }
|
|
347
|
+
});
|
|
348
|
+
if (!res.ok)
|
|
349
|
+
return;
|
|
350
|
+
const body = await res.json();
|
|
351
|
+
if (!body.version || !isNewer(body.version, current))
|
|
352
|
+
return;
|
|
353
|
+
return upgradeHint(body.version, current);
|
|
354
|
+
} catch {
|
|
355
|
+
return;
|
|
356
|
+
} finally {
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
160
361
|
// src/server.ts
|
|
161
|
-
import * as
|
|
362
|
+
import * as fs4 from "node:fs";
|
|
162
363
|
import * as http from "node:http";
|
|
163
|
-
import * as
|
|
164
|
-
import * as
|
|
165
|
-
import { randomUUID } from "node:crypto";
|
|
364
|
+
import * as os3 from "node:os";
|
|
365
|
+
import * as path4 from "node:path";
|
|
366
|
+
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
166
367
|
var MIME_BY_EXT = {
|
|
167
368
|
".png": "image/png",
|
|
168
369
|
".jpg": "image/jpeg",
|
|
@@ -170,71 +371,123 @@ var MIME_BY_EXT = {
|
|
|
170
371
|
".webp": "image/webp",
|
|
171
372
|
".gif": "image/gif"
|
|
172
373
|
};
|
|
374
|
+
var ID_PATTERN = /^\/img\/([A-Za-z0-9_-]{1,2048})(?:\/[^/]*)?$/;
|
|
375
|
+
var APP_NAME = "opencode-image";
|
|
376
|
+
function derivePort(key) {
|
|
377
|
+
const digest = createHash2("sha1").update(key).digest();
|
|
378
|
+
return 41000 + digest.readUInt16BE(0) % 4000;
|
|
379
|
+
}
|
|
173
380
|
|
|
174
381
|
class ImageServer {
|
|
175
382
|
idleMs;
|
|
383
|
+
configuredPort;
|
|
384
|
+
cache;
|
|
176
385
|
server;
|
|
177
386
|
port = 0;
|
|
178
|
-
|
|
179
|
-
|
|
387
|
+
root;
|
|
388
|
+
ready = false;
|
|
389
|
+
owned = false;
|
|
390
|
+
extra = new Map;
|
|
180
391
|
timer;
|
|
181
392
|
starting;
|
|
182
|
-
constructor(idleMs) {
|
|
393
|
+
constructor(idleMs, configuredPort, cache) {
|
|
183
394
|
this.idleMs = idleMs;
|
|
395
|
+
this.configuredPort = configuredPort;
|
|
396
|
+
this.cache = cache;
|
|
184
397
|
}
|
|
185
398
|
get baseURL() {
|
|
186
399
|
return this.port ? `http://127.0.0.1:${this.port}` : "";
|
|
187
400
|
}
|
|
188
|
-
|
|
401
|
+
idFor(file) {
|
|
402
|
+
const root = this.root;
|
|
403
|
+
if (root) {
|
|
404
|
+
const relative2 = path4.relative(root, file);
|
|
405
|
+
if (relative2 && !relative2.startsWith("..") && !path4.isAbsolute(relative2)) {
|
|
406
|
+
return Buffer.from(relative2.split(path4.sep).join("/"), "utf8").toString("base64url");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
for (const [id2, registered] of this.extra) {
|
|
410
|
+
if (registered === file)
|
|
411
|
+
return id2;
|
|
412
|
+
}
|
|
189
413
|
const id = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
190
|
-
|
|
191
|
-
this.registry.set(id, { file, mime });
|
|
414
|
+
this.extra.set(id, file);
|
|
192
415
|
return id;
|
|
193
416
|
}
|
|
194
|
-
url(
|
|
195
|
-
|
|
417
|
+
url(file, width) {
|
|
418
|
+
const query = width > 0 ? `?w=${Math.round(width)}` : "";
|
|
419
|
+
const name = encodeURIComponent(path4.basename(file));
|
|
420
|
+
return `${this.baseURL}/img/${this.idFor(file)}/${name}${query}`;
|
|
196
421
|
}
|
|
197
|
-
async start() {
|
|
198
|
-
|
|
422
|
+
async start(opts) {
|
|
423
|
+
this.root = path4.resolve(opts.outDir);
|
|
424
|
+
if (this.ready) {
|
|
199
425
|
this.arm();
|
|
200
426
|
return;
|
|
201
427
|
}
|
|
202
428
|
if (!this.starting) {
|
|
203
|
-
this.starting = this.listen().finally(() => {
|
|
429
|
+
this.starting = this.listen(opts).finally(() => {
|
|
204
430
|
this.starting = undefined;
|
|
205
431
|
});
|
|
206
432
|
}
|
|
207
433
|
await this.starting;
|
|
434
|
+
this.ready = true;
|
|
208
435
|
this.arm();
|
|
209
436
|
}
|
|
210
437
|
stop() {
|
|
211
438
|
if (this.timer)
|
|
212
439
|
clearTimeout(this.timer);
|
|
213
440
|
this.timer = undefined;
|
|
214
|
-
|
|
441
|
+
if (this.owned) {
|
|
442
|
+
this.server?.closeAllConnections?.();
|
|
443
|
+
this.server?.close();
|
|
444
|
+
}
|
|
215
445
|
this.server = undefined;
|
|
446
|
+
this.owned = false;
|
|
447
|
+
this.ready = false;
|
|
216
448
|
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
449
|
}
|
|
223
|
-
|
|
450
|
+
cacheStats() {
|
|
451
|
+
return this.cache.stats();
|
|
452
|
+
}
|
|
453
|
+
cleanCache() {
|
|
454
|
+
return this.cache.clean();
|
|
455
|
+
}
|
|
456
|
+
consumeCacheNotice() {
|
|
457
|
+
return this.cache.consumeNotice();
|
|
458
|
+
}
|
|
459
|
+
async listen(opts) {
|
|
460
|
+
const port = this.configuredPort || derivePort(path4.resolve(opts.outDir));
|
|
224
461
|
const server = http.createServer((req, res) => void this.handle(req, res));
|
|
225
462
|
server.unref();
|
|
226
|
-
await new Promise((
|
|
227
|
-
server.once("error",
|
|
228
|
-
server.listen(
|
|
463
|
+
const failure = await new Promise((resolve3) => {
|
|
464
|
+
server.once("error", resolve3);
|
|
465
|
+
server.listen(port, "127.0.0.1", () => resolve3(undefined));
|
|
229
466
|
});
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
467
|
+
if (!failure) {
|
|
468
|
+
this.server = server;
|
|
469
|
+
this.port = port;
|
|
470
|
+
this.owned = true;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
server.close();
|
|
474
|
+
if (failure.code === "EADDRINUSE" && await this.servedByUs(port, opts.outDir)) {
|
|
475
|
+
this.port = port;
|
|
476
|
+
this.owned = false;
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
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.`);
|
|
480
|
+
}
|
|
481
|
+
async servedByUs(port, outDir) {
|
|
482
|
+
try {
|
|
483
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
484
|
+
if (!res.ok)
|
|
485
|
+
return false;
|
|
486
|
+
const body = await res.json();
|
|
487
|
+
return body.app === APP_NAME && body.root === path4.resolve(outDir);
|
|
488
|
+
} catch {
|
|
489
|
+
return false;
|
|
235
490
|
}
|
|
236
|
-
this.server = server;
|
|
237
|
-
this.port = port;
|
|
238
491
|
}
|
|
239
492
|
arm() {
|
|
240
493
|
if (this.timer)
|
|
@@ -244,36 +497,70 @@ class ImageServer {
|
|
|
244
497
|
this.timer = setTimeout(() => this.stop(), this.idleMs);
|
|
245
498
|
this.timer.unref();
|
|
246
499
|
}
|
|
247
|
-
preview(source, max) {
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
500
|
+
async preview(source, max) {
|
|
501
|
+
const hit = this.cache.hit(source, max);
|
|
502
|
+
if (hit)
|
|
503
|
+
return hit;
|
|
504
|
+
const staging = path4.join(os3.tmpdir(), `opencode-image-${process.pid}-${randomUUID()}.png`);
|
|
505
|
+
const rendered = await renderPreview(source, staging, max);
|
|
506
|
+
if (rendered === source)
|
|
507
|
+
return source;
|
|
508
|
+
return this.cache.put(source, max, rendered);
|
|
509
|
+
}
|
|
510
|
+
resolve(id) {
|
|
511
|
+
const registered = this.extra.get(id);
|
|
512
|
+
if (registered)
|
|
513
|
+
return fs4.existsSync(registered) ? registered : undefined;
|
|
514
|
+
const root = this.root;
|
|
515
|
+
if (!root)
|
|
516
|
+
return;
|
|
517
|
+
let relative2;
|
|
518
|
+
try {
|
|
519
|
+
relative2 = Buffer.from(id, "base64url").toString("utf8");
|
|
520
|
+
} catch {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (!relative2 || relative2.includes("\x00") || path4.isAbsolute(relative2))
|
|
524
|
+
return;
|
|
525
|
+
const target = path4.resolve(root, relative2);
|
|
526
|
+
if (target === root || !target.startsWith(root + path4.sep))
|
|
527
|
+
return;
|
|
528
|
+
try {
|
|
529
|
+
if (!fs4.statSync(target).isFile())
|
|
530
|
+
return;
|
|
531
|
+
const real = fs4.realpathSync(target);
|
|
532
|
+
const realRoot = fs4.realpathSync(root);
|
|
533
|
+
if (real !== realRoot && !real.startsWith(realRoot + path4.sep))
|
|
534
|
+
return;
|
|
535
|
+
} catch {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
return target;
|
|
258
539
|
}
|
|
259
540
|
handle(req, res) {
|
|
260
541
|
this.arm();
|
|
261
542
|
const parsed = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
543
|
+
if (parsed.pathname === "/health") {
|
|
544
|
+
const body = JSON.stringify({ app: APP_NAME, root: this.root ?? "" });
|
|
545
|
+
res.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
|
546
|
+
res.end(body);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const id = ID_PATTERN.exec(parsed.pathname)?.[1];
|
|
550
|
+
const file = id ? this.resolve(id) : undefined;
|
|
551
|
+
if (!file) {
|
|
265
552
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
266
553
|
res.end("not found");
|
|
267
554
|
return;
|
|
268
555
|
}
|
|
269
|
-
this.send(
|
|
556
|
+
this.send(file, Number(parsed.searchParams.get("w")), res);
|
|
270
557
|
}
|
|
271
|
-
async send(
|
|
558
|
+
async send(file, width, res) {
|
|
272
559
|
try {
|
|
273
|
-
const target = width > 0 ? await this.preview(
|
|
274
|
-
const body =
|
|
560
|
+
const target = width > 0 ? await this.preview(file, Math.round(width)) : file;
|
|
561
|
+
const body = fs4.readFileSync(target);
|
|
275
562
|
res.writeHead(200, {
|
|
276
|
-
"content-type":
|
|
563
|
+
"content-type": MIME_BY_EXT[path4.extname(target).toLowerCase()] ?? "application/octet-stream",
|
|
277
564
|
"content-length": body.length,
|
|
278
565
|
"cache-control": "private, max-age=86400"
|
|
279
566
|
});
|
|
@@ -288,46 +575,68 @@ class ImageServer {
|
|
|
288
575
|
// src/index.ts
|
|
289
576
|
var z = tool.schema;
|
|
290
577
|
function resolveDestination(filename, outDir, ctx) {
|
|
291
|
-
const dir =
|
|
578
|
+
const dir = resolveOutDir(ctx.directory, outDir);
|
|
292
579
|
if (filename) {
|
|
293
|
-
const target =
|
|
580
|
+
const target = path5.isAbsolute(filename) ? filename : path5.join(dir, filename);
|
|
294
581
|
return `${target.replace(/\.[A-Za-z0-9]+$/, "")}.png`;
|
|
295
582
|
}
|
|
296
583
|
const stamp = new Date().toISOString().replace(/\..+$/, "").replace(/[:T]/g, "-");
|
|
297
584
|
const suffix = Math.random().toString(36).slice(2, 6);
|
|
298
|
-
return
|
|
585
|
+
return path5.join(dir, `image-${stamp}-${suffix}.png`);
|
|
299
586
|
}
|
|
300
587
|
function asAttachment(file) {
|
|
301
588
|
return [
|
|
302
589
|
{
|
|
303
590
|
type: "file",
|
|
304
591
|
mime: "image/png",
|
|
305
|
-
filename:
|
|
306
|
-
url: `data:image/png;base64,${
|
|
592
|
+
filename: path5.basename(file),
|
|
593
|
+
url: `data:image/png;base64,${fs5.readFileSync(file).toString("base64")}`
|
|
307
594
|
}
|
|
308
595
|
];
|
|
309
596
|
}
|
|
310
597
|
var OpenCodeImagePlugin = async (_input, rawOptions) => {
|
|
311
598
|
const options = resolveOptions(rawOptions);
|
|
312
|
-
const
|
|
313
|
-
const
|
|
599
|
+
const cache = new PreviewCache(options.cacheDir, options.cacheLimit);
|
|
600
|
+
const server = new ImageServer(options.serverIdleMs, options.serverPort, cache);
|
|
601
|
+
const selfVersion = readOwnVersion();
|
|
602
|
+
let updateHint;
|
|
603
|
+
let updateChecked = false;
|
|
604
|
+
const upgradeNotice = async () => {
|
|
605
|
+
if (!options.updateCheck || updateChecked || !selfVersion)
|
|
606
|
+
return updateHint;
|
|
607
|
+
updateChecked = true;
|
|
608
|
+
updateHint = await checkForUpdate(selfVersion);
|
|
609
|
+
return updateHint;
|
|
610
|
+
};
|
|
611
|
+
const result = async (label, prompt, file, ctx, usage) => {
|
|
314
612
|
const { width, height } = await imageDimensions(file);
|
|
315
|
-
const id = server.register(file);
|
|
316
613
|
let display;
|
|
317
614
|
try {
|
|
318
|
-
await server.start();
|
|
319
|
-
|
|
615
|
+
await server.start({ outDir: resolveOutDir(ctx.directory, options.outDir) });
|
|
616
|
+
const alt = prompt.slice(0, 60);
|
|
617
|
+
const lines = [`})`];
|
|
618
|
+
if (options.displayMaxSize > 0)
|
|
619
|
+
lines.push(`[查看原图](${server.url(file, 0)})`);
|
|
620
|
+
display = lines.join(`
|
|
621
|
+
`);
|
|
320
622
|
} catch (error) {
|
|
321
623
|
display = `[opencode-image: local image server unavailable, cannot render inline: ${String(error)}]`;
|
|
322
624
|
}
|
|
625
|
+
const notice = await upgradeNotice();
|
|
626
|
+
const output = [
|
|
627
|
+
display,
|
|
628
|
+
`${label} saved to ${file} (${width}x${height})`,
|
|
629
|
+
`Refine it by calling image_edit with image="${file}" and a prompt describing the change.`,
|
|
630
|
+
`IMPORTANT: copy the Markdown image line above verbatim into your reply text, otherwise the user cannot see the picture.`
|
|
631
|
+
];
|
|
632
|
+
if (notice)
|
|
633
|
+
output.push(notice);
|
|
634
|
+
const cacheNotice = server.consumeCacheNotice();
|
|
635
|
+
if (cacheNotice)
|
|
636
|
+
output.push(cacheNotice);
|
|
323
637
|
return {
|
|
324
638
|
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(`
|
|
639
|
+
output: output.join(`
|
|
331
640
|
`),
|
|
332
641
|
metadata: { file, prompt, usage },
|
|
333
642
|
attachments: asAttachment(file)
|
|
@@ -341,24 +650,24 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
|
|
|
341
650
|
const raw = await fetchImageBytes(remote, signal);
|
|
342
651
|
const destination = resolveDestination(args.filename, options.outDir, ctx);
|
|
343
652
|
await persistImage(raw, destination, options.maxSize);
|
|
344
|
-
return result("Image generated", args.prompt, destination, remote.usage);
|
|
653
|
+
return result("Image generated", args.prompt, destination, ctx, remote.usage);
|
|
345
654
|
} finally {
|
|
346
655
|
clear();
|
|
347
656
|
}
|
|
348
657
|
};
|
|
349
658
|
const edit = async (args, ctx) => {
|
|
350
|
-
const source =
|
|
351
|
-
if (!
|
|
659
|
+
const source = path5.isAbsolute(args.image) ? args.image : path5.resolve(ctx.directory, args.image);
|
|
660
|
+
if (!fs5.existsSync(source))
|
|
352
661
|
throw new Error(`image_edit: source image not found: ${source}`);
|
|
353
662
|
ctx.metadata({ title: `Editing image: ${args.prompt.slice(0, 60)}` });
|
|
354
663
|
const { signal, clear } = withTimeout(ctx.abort, options.timeoutMs);
|
|
355
664
|
try {
|
|
356
665
|
const upload = await toPngBuffer(source, options.maxSize);
|
|
357
|
-
const remote = await editImage(options, upload, `${
|
|
666
|
+
const remote = await editImage(options, upload, `${path5.basename(source, path5.extname(source))}.png`, args.prompt, signal);
|
|
358
667
|
const raw = await fetchImageBytes(remote, signal);
|
|
359
668
|
const destination = resolveDestination(args.filename, options.outDir, ctx);
|
|
360
669
|
await persistImage(raw, destination, options.maxSize);
|
|
361
|
-
return result("Image edited", args.prompt, destination, remote.usage);
|
|
670
|
+
return result("Image edited", args.prompt, destination, ctx, remote.usage);
|
|
362
671
|
} finally {
|
|
363
672
|
clear();
|
|
364
673
|
}
|
|
@@ -366,7 +675,7 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
|
|
|
366
675
|
return {
|
|
367
676
|
tool: {
|
|
368
677
|
image_generate: tool({
|
|
369
|
-
description: "Generate a new image from a text prompt using an OpenAI-compatible image API. " + "The result
|
|
678
|
+
description: "Generate a new image from a text prompt using an OpenAI-compatible image API. " + "The result starts with a Markdown image line (plus a separate 查看原图 link when displayMaxSize is set) that you MUST copy verbatim into your reply text, " + "If the output contains a line starting with [请转述给用户], relay that line to the user in your reply. " + "otherwise the user cannot see the picture. To iterate on a result, call image_edit with the returned path.",
|
|
370
679
|
args: {
|
|
371
680
|
prompt: z.string().describe("Detailed description of the image to generate"),
|
|
372
681
|
filename: z.string().optional().describe("Optional output file name or path; defaults to a timestamped name")
|
|
@@ -374,14 +683,51 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
|
|
|
374
683
|
execute: generate
|
|
375
684
|
}),
|
|
376
685
|
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
|
|
686
|
+
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 starts with a Markdown image line (plus a separate 查看原图 link when displayMaxSize is set) that you MUST copy verbatim into your reply text, " + "If the output contains a line starting with [请转述给用户], relay that line to the user in your reply. " + "otherwise the user cannot see the picture.",
|
|
378
687
|
args: {
|
|
379
688
|
image: z.string().describe("Path to the source image, normally the file returned by image_generate/image_edit"),
|
|
380
689
|
prompt: z.string().describe("What to change, e.g. 'make the hat red, keep everything else identical'"),
|
|
381
690
|
filename: z.string().optional().describe("Optional output file name or path; defaults to a timestamped name")
|
|
382
691
|
},
|
|
383
692
|
execute: edit
|
|
693
|
+
}),
|
|
694
|
+
image_cache: tool({
|
|
695
|
+
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.",
|
|
696
|
+
args: {
|
|
697
|
+
action: z.enum(["status", "clean"]).describe("status = 查看缓存;clean = 清空缓存")
|
|
698
|
+
},
|
|
699
|
+
execute: async (args) => {
|
|
700
|
+
if (args.action === "clean") {
|
|
701
|
+
const removed = server.cleanCache();
|
|
702
|
+
return {
|
|
703
|
+
title: `已清理图片缓存(${removed.count} 个 / ${humanBytes(removed.bytes)})`,
|
|
704
|
+
output: [
|
|
705
|
+
`已清理 ${removed.count} 个预览文件,共 ${humanBytes(removed.bytes)}。`,
|
|
706
|
+
`缓存目录:${removed.dir}`,
|
|
707
|
+
`注意:这不影响 outDir 里的原图,会话里的图片仍能正常显示(需要时会重新生成缩略图)。`
|
|
708
|
+
].join(`
|
|
709
|
+
`),
|
|
710
|
+
metadata: removed
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const stats = server.cacheStats();
|
|
714
|
+
return {
|
|
715
|
+
title: `图片缓存:${stats.count} 个 / ${humanBytes(stats.bytes)}`,
|
|
716
|
+
output: [
|
|
717
|
+
`缓存目录:${stats.dir}`,
|
|
718
|
+
`文件数量:${stats.count}`,
|
|
719
|
+
`占用大小:${humanBytes(stats.bytes)}`,
|
|
720
|
+
`最旧一个:${stats.oldestMs === undefined ? "(空缓存)" : `已存在 ${humanAge(stats.oldestMs)}`}`,
|
|
721
|
+
`上限:${options.cacheLimit} 个(可通过 cacheLimit 配置,0 = 不限制)`
|
|
722
|
+
].join(`
|
|
723
|
+
`),
|
|
724
|
+
metadata: stats
|
|
725
|
+
};
|
|
726
|
+
}
|
|
384
727
|
})
|
|
728
|
+
},
|
|
729
|
+
dispose: async () => {
|
|
730
|
+
server.stop();
|
|
385
731
|
}
|
|
386
732
|
};
|
|
387
733
|
};
|
package/dist/options.d.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/** opencode 的数据目录(与 opencode.db 同级),图片默认落在这里而不是项目里 */
|
|
2
|
+
export declare function defaultImageDir(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* 解析图片输出目录:
|
|
5
|
+
* - 未配置 → 全局数据目录,和项目/会话解耦
|
|
6
|
+
* - 相对路径 → 基于当前项目目录(例如 ".opencode-images" 就回到项目里)
|
|
7
|
+
* - 绝对路径 → 原样使用
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveOutDir(directory: string, outDir?: string): string;
|
|
1
10
|
/** 插件可配置项,通过 opencode 配置的 `plugin` 元组第二个元素传入 */
|
|
2
11
|
export type PluginOptions = {
|
|
3
12
|
/** OpenAI 兼容的图像接口地址,默认从 PI_IMAGE_BASE_URL 读取 */
|
|
@@ -6,21 +15,35 @@ export type PluginOptions = {
|
|
|
6
15
|
apiKey?: string;
|
|
7
16
|
/** 图像模型名 */
|
|
8
17
|
model?: string;
|
|
9
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* 图片输出目录。默认是全局数据目录(建议保持默认,跟项目/会话解耦);
|
|
20
|
+
* 填相对路径(如 ".opencode-images")会落在当前项目里。
|
|
21
|
+
*/
|
|
10
22
|
outDir?: string;
|
|
11
23
|
/** 落盘图片的最长边上限(像素),超出会等比缩小 */
|
|
12
24
|
maxSize?: number;
|
|
13
|
-
/**
|
|
25
|
+
/** 对话里展示时的最长边(像素)。0 = 不缩放,直接显示原图(默认,右键复制即原图) */
|
|
14
26
|
displayMaxSize?: number;
|
|
27
|
+
/** 本地图片服务端口;默认 0 = 由项目目录派生(同一项目恒定),需要避开占用时手动指定 */
|
|
28
|
+
serverPort?: number;
|
|
15
29
|
/** 本地图片服务的空闲关闭时间(毫秒),0 表示不主动关闭 */
|
|
16
30
|
serverIdleMs?: number;
|
|
31
|
+
/** 首次出图时查一次 npm 最新版,落后则在工具输出末尾附升级提示。默认开启 */
|
|
32
|
+
updateCheck?: boolean;
|
|
33
|
+
/** 对话里展示用的缩略图缓存目录,默认 <系统临时目录>/opencode-image */
|
|
34
|
+
cacheDir?: string;
|
|
35
|
+
/** 缩略图缓存最多保留多少个文件,超出按最久未访问淘汰;0 表示不限制 */
|
|
36
|
+
cacheLimit?: number;
|
|
17
37
|
/** 单次图像请求超时(毫秒) */
|
|
18
38
|
timeoutMs?: number;
|
|
19
39
|
};
|
|
20
40
|
export declare const DEFAULT_BASE_URL = "https://pi-ai.roiscloud.com/v1";
|
|
21
41
|
export declare const DEFAULT_MODEL = "gpt-image-2";
|
|
22
42
|
export declare const DEFAULT_MAX_SIZE = 1024;
|
|
23
|
-
|
|
43
|
+
/** 0 = 不缩放,直接把落盘的原图显示出来(右键复制/另存拿到的就是原图) */
|
|
44
|
+
export declare const DEFAULT_DISPLAY_MAX_SIZE = 0;
|
|
45
|
+
/** 0 表示由项目目录派生端口,保证同一项目跨重启链接不变 */
|
|
46
|
+
export declare const DEFAULT_SERVER_PORT = 0;
|
|
24
47
|
export declare const DEFAULT_SERVER_IDLE_MS = 0;
|
|
25
48
|
export declare const DEFAULT_TIMEOUT_MS = 600000;
|
|
26
49
|
export type ResolvedOptions = {
|
|
@@ -30,7 +53,11 @@ export type ResolvedOptions = {
|
|
|
30
53
|
outDir?: string;
|
|
31
54
|
maxSize: number;
|
|
32
55
|
displayMaxSize: number;
|
|
56
|
+
serverPort: number;
|
|
33
57
|
serverIdleMs: number;
|
|
58
|
+
updateCheck: boolean;
|
|
59
|
+
cacheDir: string;
|
|
60
|
+
cacheLimit: number;
|
|
34
61
|
timeoutMs: number;
|
|
35
62
|
};
|
|
36
63
|
/** 合并默认值、环境变量与用户配置;缺少 apiKey 时直接抛错 */
|
package/dist/server.d.ts
CHANGED
|
@@ -1,35 +1,68 @@
|
|
|
1
|
+
import { PreviewCache } from "./cache.js";
|
|
2
|
+
/**
|
|
3
|
+
* 端口由「图片根目录」派生:同一个根目录永远同一个端口,所以历史消息里的链接跨重启仍然有效。
|
|
4
|
+
* 默认根目录是全局数据目录,因此全机通用一个端口、一个服务;
|
|
5
|
+
* 若把 outDir 指到项目里,则那个项目有自己稳定的端口。需要覆盖时用 serverPort 配置项。
|
|
6
|
+
*/
|
|
7
|
+
export declare function derivePort(key: string): number;
|
|
1
8
|
/**
|
|
2
9
|
* 本地图片服务:把生成结果通过 http://127.0.0.1:<port>/img/<id>?w=<width> 暴露给客户端渲染。
|
|
3
10
|
*
|
|
4
11
|
* 客户端限制说明:图片只有出现在助手回复的 Markdown 正文里才会渲染,
|
|
5
12
|
* 而正文里的 file:// 会被 Chromium 拦截,所以必须走本地 HTTP。
|
|
6
13
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
14
|
+
* 稳定性:id 是「文件相对 outDir 的路径」做 base64url 编码,可逆且稳定。
|
|
15
|
+
* 因此服务不需要任何内存登记表——同一个文件在任何一次启动里都能解析出同一个 id,
|
|
16
|
+
* 重启后历史消息里的旧链接依然可访问。
|
|
17
|
+
*
|
|
18
|
+
* 安全边界:请求里的 id 只允许解析成 outDir 内部的普通文件,
|
|
19
|
+
* 解析后会做 containment + realpath 双重校验,防路径穿越与符号链接逃逸。
|
|
9
20
|
*/
|
|
10
21
|
export declare class ImageServer {
|
|
11
22
|
private readonly idleMs;
|
|
23
|
+
private readonly configuredPort;
|
|
24
|
+
private readonly cache;
|
|
12
25
|
private server?;
|
|
13
26
|
private port;
|
|
14
|
-
private
|
|
15
|
-
private
|
|
27
|
+
private root?;
|
|
28
|
+
private ready;
|
|
29
|
+
private owned;
|
|
30
|
+
/** outDir 之外的文件退回进程内随机 id(仅本次运行有效) */
|
|
31
|
+
private extra;
|
|
16
32
|
private timer?;
|
|
17
33
|
private starting?;
|
|
18
|
-
constructor(idleMs: number);
|
|
34
|
+
constructor(idleMs: number, configuredPort: number, cache: PreviewCache);
|
|
19
35
|
get baseURL(): string;
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
/** 生成稳定 id;outDir 内的文件跨重启一致 */
|
|
37
|
+
idFor(file: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* width > 0 时给出缩略图地址;width <= 0 时是原图地址(不带查询参数)。
|
|
40
|
+
* 末尾拼一段真实的文件名,纯粹为了让浏览器「另存为」时有个正常名字。
|
|
41
|
+
*/
|
|
42
|
+
url(file: string, width: number): string;
|
|
43
|
+
/** 懒启动:首次需要时按派生端口监听;unref 后不阻塞宿主进程退出 */
|
|
44
|
+
start(opts: {
|
|
45
|
+
outDir: string;
|
|
46
|
+
}): Promise<void>;
|
|
25
47
|
/**
|
|
26
48
|
* 关闭服务并清理预览缓存。
|
|
27
49
|
* 默认不调用:历史消息里的图片会重新请求,提前关闭会 404。
|
|
28
50
|
*/
|
|
29
51
|
stop(): void;
|
|
52
|
+
/** 缩略图缓存的统计 / 清理,透传给上层工具与展示通知 */
|
|
53
|
+
cacheStats(): import("./cache.js").CacheStats;
|
|
54
|
+
cleanCache(): {
|
|
55
|
+
dir: string;
|
|
56
|
+
count: number;
|
|
57
|
+
bytes: number;
|
|
58
|
+
};
|
|
59
|
+
consumeCacheNotice(): string | undefined;
|
|
30
60
|
private listen;
|
|
61
|
+
private servedByUs;
|
|
31
62
|
private arm;
|
|
32
63
|
private preview;
|
|
64
|
+
/** id -> 文件;只接受 outDir 内、真实存在、且 realpath 不逃逸的普通文件 */
|
|
65
|
+
private resolve;
|
|
33
66
|
private handle;
|
|
34
67
|
private send;
|
|
35
68
|
}
|
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.2",
|
|
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
|
}
|