@xiaoqiong0v0/opencode-file-tool 1.0.2 → 1.0.3
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/dist/file-tool.js +405 -0
- package/package.json +14 -4
- package/file-tool.js +0 -397
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import createLogger from "@xiaoqiong0v0/opencode-plugin-logger";
|
|
4
|
+
import { rm } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
const CONFIG_DIR = process.env.HOME || process.env.USERPROFILE || "";
|
|
7
|
+
const CONFIG_PATH = join(CONFIG_DIR, ".config/opencode/file-tool.jsonc");
|
|
8
|
+
const OPENCODE_CONFIG = join(CONFIG_DIR, ".config/opencode/opencode.json");
|
|
9
|
+
const CACHE_DIR = join(CONFIG_DIR, ".opencode/plugins-cache");
|
|
10
|
+
const log = createLogger("file-tool");
|
|
11
|
+
let _cfg = null;
|
|
12
|
+
const FILE_TOOL_CFG_SAMPLE = `{
|
|
13
|
+
// 视觉分析模型(provider/modelId),file_tool set-provider 切换
|
|
14
|
+
"model": "",
|
|
15
|
+
"maxTokens": 4096,
|
|
16
|
+
"timeout": 60000,
|
|
17
|
+
"maxFileSizeMB": 20,
|
|
18
|
+
// 缓存消息数量上限,超过则删除最早的
|
|
19
|
+
"maxCacheMessages": 3,
|
|
20
|
+
// 工具提示语言:zh=中文, en=English
|
|
21
|
+
"lang": "en"
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
let MAX_CACHE_MSGS = 3;
|
|
25
|
+
let LANG = "en";
|
|
26
|
+
const TX = {
|
|
27
|
+
file_not_found: { zh: "文件不存在: {path}", en: "File not found: {path}" },
|
|
28
|
+
file_id_not_found: { zh: "文件ID不存在: {id}", en: "File ID not found: {id}" },
|
|
29
|
+
file_data_not_found: { zh: "文件数据不存在: {id}", en: "File data not found: {id}" },
|
|
30
|
+
not_an_image: { zh: "不是图片文件: {name} ({mime})", en: "Not an image: {name} ({mime})" },
|
|
31
|
+
unsupported_source: { zh: "不支持的图片来源: {source}", en: "Unsupported source: {source}" },
|
|
32
|
+
describe_image: { zh: "请详细描述这张图片({name})的内容", en: "Describe this image ({name})" },
|
|
33
|
+
current_model: { zh: "当前模型: {model}\n可用模型:\n{list}", en: "Current model: {model}\nAvailable models:\n{list}" },
|
|
34
|
+
model_not_set: { zh: "未设置", en: "not set" },
|
|
35
|
+
model_switched: { zh: "视觉模型已切换为: {model}", en: "Vision model set to: {model}" },
|
|
36
|
+
specify_model: { zh: "请指定模型名", en: "Specify a model name" },
|
|
37
|
+
unknown_cmd: { zh: "未知命令: {cmd}\n可用: list-provider, set-provider <model>, list-cache [all|N|main|main N]", en: "Unknown command: {cmd}\nAvailable: list-provider, set-provider <model>, list-cache [all|N|main|main N]" },
|
|
38
|
+
config_error: { zh: "请在 file-tool.jsonc 中配置 model (provider/modelId) 或 apiKey+apiBaseUrl+model", en: "Set model (provider/modelId) or apiKey+apiBaseUrl+model in file-tool.jsonc" },
|
|
39
|
+
meta_failed: { zh: "分析失败", en: "Failed" },
|
|
40
|
+
meta_skip: { zh: "跳过", en: "Skip" },
|
|
41
|
+
meta_not_found: { zh: "文件不存在", en: "Not found" },
|
|
42
|
+
meta_image: { zh: "图片", en: "Image" },
|
|
43
|
+
meta_error: { zh: "分析出错", en: "Error" },
|
|
44
|
+
no_cache: { zh: "[] (无缓存)", en: "[] (no cache)" },
|
|
45
|
+
vision_prompt_default: { zh: "请详细描述这张图片的内容,返回格式: [文件名] 描述", en: "Describe this image in detail, format: [filename] description" },
|
|
46
|
+
err_resolve_config: { zh: "无法解析模型配置: {model}。请在 file-tool.jsonc 中配置 model (provider/modelId) 或 apiKey+apiBaseUrl+model", en: "Cannot resolve model config: {model}. Set model (provider/modelId) or apiKey+apiBaseUrl+model in file-tool.jsonc" },
|
|
47
|
+
err_api: { zh: "API {status}: {msg}", en: "API {status}: {msg}" },
|
|
48
|
+
empty_response: { zh: "(空)", en: "(empty)" },
|
|
49
|
+
cmd_desc: { zh: "切换视觉分析模型", en: "Switch vision analysis model" },
|
|
50
|
+
cmd_template: { zh: "直接调用 file_tool 工具。默认 `list-provider`,`set-provider <模型名>` 切换模型,`list-cache` 查看缓存。", en: "Call file_tool tool directly. Default: `list-provider`. Use `set-provider <model>` to switch. Use `list-cache` to view cached files." },
|
|
51
|
+
};
|
|
52
|
+
const T = (key, params) => {
|
|
53
|
+
const entry = TX[key] || { zh: key, en: key };
|
|
54
|
+
const t = LANG === "zh" ? entry.zh : entry.en;
|
|
55
|
+
if (!params)
|
|
56
|
+
return t;
|
|
57
|
+
return Object.entries(params).reduce((s, [k, v]) => s.replace(`{${k}}`, v), t);
|
|
58
|
+
};
|
|
59
|
+
function loadCfg() {
|
|
60
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(CONFIG_PATH, FILE_TOOL_CFG_SAMPLE, "utf-8");
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
}
|
|
66
|
+
const raw = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {};
|
|
67
|
+
_cfg = resolveConfig(raw);
|
|
68
|
+
MAX_CACHE_MSGS = (raw.maxCacheMessages > 0) ? raw.maxCacheMessages : 3;
|
|
69
|
+
LANG = (raw.lang === "zh" ? "zh" : "en");
|
|
70
|
+
return _cfg;
|
|
71
|
+
}
|
|
72
|
+
function reloadCfg() { loadCfg(); }
|
|
73
|
+
try {
|
|
74
|
+
loadCfg();
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
log.error("初始化失败", e instanceof Error ? e : Error(String(e)));
|
|
78
|
+
}
|
|
79
|
+
const DESC = {
|
|
80
|
+
analyze_image: {
|
|
81
|
+
zh: "用多模态模型分析图片。先调 file_tool list-cache 拿到文件ID,再用 file_id:N 分析。",
|
|
82
|
+
en: "Analyze images with multimodal model. Call file_tool list-cache first to get file IDs, then use file_id:N.",
|
|
83
|
+
},
|
|
84
|
+
file_tool: {
|
|
85
|
+
zh: "文件缓存管理。当你在上下文中看到 [Image N] 或收到 Cannot read 图片错误时,立即调 list-cache 获取文件ID,再用 analyze_image file_id:N 分析。",
|
|
86
|
+
en: "File cache manager. When you see [Image N] or a Cannot read image error, call list-cache to get file IDs, then use analyze_image file_id:N.",
|
|
87
|
+
},
|
|
88
|
+
file_tool_args: {
|
|
89
|
+
zh: "list-cache, list-cache main, list-provider, set-provider <model>",
|
|
90
|
+
en: "list-cache, list-cache main, list-provider, set-provider <model>",
|
|
91
|
+
},
|
|
92
|
+
analyze_args_source: { zh: "file_path=file_id:N", en: "file_path=file_id:N" },
|
|
93
|
+
analyze_args_data: { zh: "file_id:N 或 base64", en: "file_id:N or base64" },
|
|
94
|
+
analyze_args_prompt: { zh: "分析提示", en: "prompt" },
|
|
95
|
+
};
|
|
96
|
+
function getCfg() {
|
|
97
|
+
if (_cfg)
|
|
98
|
+
return _cfg;
|
|
99
|
+
const raw = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {};
|
|
100
|
+
_cfg = resolveConfig(raw);
|
|
101
|
+
return _cfg;
|
|
102
|
+
}
|
|
103
|
+
function resolveConfig(fileConfig) {
|
|
104
|
+
const model = fileConfig.model;
|
|
105
|
+
if (!model)
|
|
106
|
+
throw new Error(T("config_error"));
|
|
107
|
+
if (fileConfig.apiKey && fileConfig.apiBaseUrl) {
|
|
108
|
+
const mId = model.includes("/") ? model.split("/").pop() : model;
|
|
109
|
+
return { model, apiKey: fileConfig.apiKey, baseURL: fileConfig.apiBaseUrl, modelId: mId, maxTokens: fileConfig.maxTokens || 4096, timeout: fileConfig.timeout || 60000 };
|
|
110
|
+
}
|
|
111
|
+
if (model.includes("/")) {
|
|
112
|
+
const [provider, modelId] = model.split("/");
|
|
113
|
+
try {
|
|
114
|
+
const raw = readFileSync(OPENCODE_CONFIG, "utf-8");
|
|
115
|
+
const oc = JSON.parse(raw);
|
|
116
|
+
const prov = oc.provider?.[provider];
|
|
117
|
+
if (prov?.options?.apiKey && prov?.options?.baseURL)
|
|
118
|
+
return { model, apiKey: prov.options.apiKey, baseURL: prov.options.baseURL, modelId, maxTokens: fileConfig.maxTokens || 4096, timeout: fileConfig.timeout || 60000 };
|
|
119
|
+
}
|
|
120
|
+
catch { }
|
|
121
|
+
}
|
|
122
|
+
throw new Error(T("err_resolve_config", { model }));
|
|
123
|
+
}
|
|
124
|
+
function readJsonc(path) {
|
|
125
|
+
const raw = readFileSync(path, "utf-8").replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
126
|
+
return JSON.parse(raw);
|
|
127
|
+
}
|
|
128
|
+
async function callVisionApi(imageUrl, prompt) {
|
|
129
|
+
const cfg = getCfg();
|
|
130
|
+
const resp = await fetch(`${cfg.baseURL}/chat/completions`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ model: cfg.modelId, messages: [{ role: "user", content: [{ type: "text", text: prompt || T("vision_prompt_default") }, { type: "image_url", image_url: { url: imageUrl } }] }], max_tokens: cfg.maxTokens }),
|
|
134
|
+
signal: AbortSignal.timeout(cfg.timeout || 60000),
|
|
135
|
+
});
|
|
136
|
+
if (!resp.ok)
|
|
137
|
+
throw new Error(T("err_api", { status: String(resp.status), msg: (await resp.text().catch(() => "unknown")).slice(0, 200) }));
|
|
138
|
+
const data = await resp.json();
|
|
139
|
+
const msg = data.choices?.[0]?.message;
|
|
140
|
+
return msg?.content || msg?.reasoning_content || T("empty_response");
|
|
141
|
+
}
|
|
142
|
+
const sessionParents = new Map();
|
|
143
|
+
const knownSessions = new Set();
|
|
144
|
+
function getRootSession(sid) {
|
|
145
|
+
let current = sid;
|
|
146
|
+
while (sessionParents.has(current))
|
|
147
|
+
current = sessionParents.get(current);
|
|
148
|
+
return current;
|
|
149
|
+
}
|
|
150
|
+
function findFileInChain(sid, fid) {
|
|
151
|
+
const store = readSession(sid);
|
|
152
|
+
const file = store.files[fid];
|
|
153
|
+
if (file)
|
|
154
|
+
return { store, file };
|
|
155
|
+
const parentSid = sessionParents.get(sid);
|
|
156
|
+
if (parentSid)
|
|
157
|
+
return findFileInChain(parentSid, fid);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function sessionDir(sid) { return join(CACHE_DIR, sid); }
|
|
161
|
+
function filesDir(sid) {
|
|
162
|
+
const d = join(sessionDir(sid), "files");
|
|
163
|
+
if (!existsSync(d))
|
|
164
|
+
mkdirSync(d, { recursive: true });
|
|
165
|
+
return d;
|
|
166
|
+
}
|
|
167
|
+
function readSession(sid) {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(readFileSync(join(sessionDir(sid), "files.json"), "utf-8"));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return { nextId: 1, files: {}, messages: [] };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function writeSession(sid, data) {
|
|
176
|
+
const dir = sessionDir(sid);
|
|
177
|
+
if (!existsSync(dir))
|
|
178
|
+
mkdirSync(dir, { recursive: true });
|
|
179
|
+
const msgs = data.messages || [];
|
|
180
|
+
if (msgs.length > MAX_CACHE_MSGS) {
|
|
181
|
+
const expired = msgs.splice(0, msgs.length - MAX_CACHE_MSGS);
|
|
182
|
+
for (const msg of expired) {
|
|
183
|
+
for (const fid of (msg.fileIds || [])) {
|
|
184
|
+
delete data.files[fid];
|
|
185
|
+
const path = join(dir, "files", fid + ".b64");
|
|
186
|
+
rm(path, { force: true }).then(() => {
|
|
187
|
+
log.info(`${sid}: Deleted file ${path}`);
|
|
188
|
+
}).catch((err) => {
|
|
189
|
+
log.error(`${sid}: Failed to delete file ${path}`, err);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
writeFileSync(join(dir, "files.json"), JSON.stringify(data, null, 2));
|
|
195
|
+
}
|
|
196
|
+
function writeFileData(sid, fid, url) {
|
|
197
|
+
const b64 = url.replace(/^data:\w+\/\w+;base64,/, "");
|
|
198
|
+
writeFileSync(join(filesDir(sid), fid + ".b64"), b64, "utf-8");
|
|
199
|
+
}
|
|
200
|
+
function readFileData(sid, fid) {
|
|
201
|
+
try {
|
|
202
|
+
const b64 = readFileSync(join(filesDir(sid), fid + ".b64"), "utf-8");
|
|
203
|
+
const meta = readSession(sid).files[fid];
|
|
204
|
+
return `data:${meta?.mime || "image/png"};base64,${b64}`;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
const parentSid = sessionParents.get(sid);
|
|
208
|
+
if (parentSid)
|
|
209
|
+
return readFileData(parentSid, fid);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function deleteSession(sid) {
|
|
214
|
+
const dir = sessionDir(sid);
|
|
215
|
+
if (existsSync(dir))
|
|
216
|
+
rmSync(dir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
// V1 export:工具 + 事件
|
|
219
|
+
export const FileTool = async () => {
|
|
220
|
+
log.loaded();
|
|
221
|
+
return {
|
|
222
|
+
config: async (config) => {
|
|
223
|
+
const commands = config.command ?? {};
|
|
224
|
+
commands["file-tool"] = { template: T("cmd_template"), description: T("cmd_desc") };
|
|
225
|
+
config.command = commands;
|
|
226
|
+
},
|
|
227
|
+
event: async ({ event }) => {
|
|
228
|
+
const props = event.properties;
|
|
229
|
+
const sid = props?.sessionID;
|
|
230
|
+
if (event.type === "session.created" && sid) {
|
|
231
|
+
knownSessions.add(sid);
|
|
232
|
+
if (props?.parentID)
|
|
233
|
+
sessionParents.set(sid, props.parentID);
|
|
234
|
+
}
|
|
235
|
+
if (event.type === "session.updated" && sid) {
|
|
236
|
+
if (!knownSessions.has(sid))
|
|
237
|
+
knownSessions.add(sid);
|
|
238
|
+
}
|
|
239
|
+
if (event.type === "session.deleted" && sid) {
|
|
240
|
+
deleteSession(sid);
|
|
241
|
+
knownSessions.delete(sid);
|
|
242
|
+
sessionParents.delete(sid);
|
|
243
|
+
for (const [child, parent] of sessionParents) {
|
|
244
|
+
if (parent === sid)
|
|
245
|
+
sessionParents.delete(child);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (event.type === "message.part.updated") {
|
|
249
|
+
const part = props?.part;
|
|
250
|
+
if (part?.type === "file" && (part?.mime || "").startsWith("image/")) {
|
|
251
|
+
const fn = (part.filename || part.name || "");
|
|
252
|
+
if (fn) {
|
|
253
|
+
const data = readSession(sid || "");
|
|
254
|
+
if (!sid)
|
|
255
|
+
return;
|
|
256
|
+
const fid = data.nextId++;
|
|
257
|
+
const msgId = (part.messageID || "");
|
|
258
|
+
data.files[fid] = { id: fid, filename: fn, mime: part.mime, msgId };
|
|
259
|
+
writeFileData(sid, fid, (part.url || ""));
|
|
260
|
+
const msgs = data.messages;
|
|
261
|
+
const last = msgs[msgs.length - 1];
|
|
262
|
+
if (last && last.msgId === msgId) {
|
|
263
|
+
last.fileIds.push(fid);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
msgs.push({ msgId, fileIds: [fid] });
|
|
267
|
+
}
|
|
268
|
+
writeSession(sid, data);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
tool: {
|
|
274
|
+
analyze_image: tool({
|
|
275
|
+
description: DESC.analyze_image[LANG],
|
|
276
|
+
args: {
|
|
277
|
+
source: tool.schema.enum(["file_path", "base64"]).describe(DESC.analyze_args_source[LANG]),
|
|
278
|
+
data: tool.schema.string().describe(DESC.analyze_args_data[LANG]),
|
|
279
|
+
prompt: tool.schema.string().optional().describe(DESC.analyze_args_prompt[LANG]),
|
|
280
|
+
},
|
|
281
|
+
execute: async ({ source, data, prompt }, context) => {
|
|
282
|
+
let imageUrl, fileName = "";
|
|
283
|
+
if (source === "file_path" && data.startsWith("file_id:")) {
|
|
284
|
+
const fid = parseInt(data.slice(8), 10);
|
|
285
|
+
const found = findFileInChain(context.sessionID, fid);
|
|
286
|
+
if (!found) {
|
|
287
|
+
context.metadata?.({ title: T("meta_failed") });
|
|
288
|
+
return T("file_id_not_found", { id: String(fid) });
|
|
289
|
+
}
|
|
290
|
+
const file = found.file;
|
|
291
|
+
if (!file.mime.startsWith("image/")) {
|
|
292
|
+
context.metadata?.({ title: T("meta_skip") });
|
|
293
|
+
return T("not_an_image", { name: file.filename, mime: file.mime });
|
|
294
|
+
}
|
|
295
|
+
fileName = file.filename;
|
|
296
|
+
imageUrl = readFileData(context.sessionID, fid) || "";
|
|
297
|
+
if (!imageUrl) {
|
|
298
|
+
context.metadata?.({ title: T("meta_failed") });
|
|
299
|
+
return T("file_data_not_found", { id: String(fid) });
|
|
300
|
+
}
|
|
301
|
+
prompt = prompt || T("describe_image", { name: fileName });
|
|
302
|
+
}
|
|
303
|
+
else if (source === "file_path") {
|
|
304
|
+
if (!existsSync(data)) {
|
|
305
|
+
const tryPath = join(context.directory, data);
|
|
306
|
+
if (existsSync(tryPath))
|
|
307
|
+
data = tryPath;
|
|
308
|
+
}
|
|
309
|
+
if (!existsSync(data)) {
|
|
310
|
+
context.metadata?.({ title: T("meta_not_found") });
|
|
311
|
+
return T("file_not_found", { path: data });
|
|
312
|
+
}
|
|
313
|
+
const ext = data.split(".").pop()?.toLowerCase() || "";
|
|
314
|
+
const mimeMap = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", bmp: "image/bmp", gif: "image/gif", webp: "image/webp" };
|
|
315
|
+
const mime = mimeMap[ext] || "image/png";
|
|
316
|
+
fileName = data.split(/[/\\]/).pop() || "";
|
|
317
|
+
imageUrl = `data:${mime};base64,${readFileSync(data).toString("base64")}`;
|
|
318
|
+
}
|
|
319
|
+
else if (source === "base64") {
|
|
320
|
+
imageUrl = `data:image/png;base64,${data.replace(/^data:image\/\w+;base64,/, "")}`;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
return T("unsupported_source", { source });
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const result = await callVisionApi(imageUrl, prompt || "");
|
|
327
|
+
context.metadata?.({ title: `[Vision] ${fileName || T("meta_image")}`, metadata: { sessionID: context.sessionID, messageID: context.messageID } });
|
|
328
|
+
return "[Vision] " + result;
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
332
|
+
context.metadata?.({ title: T("meta_error") });
|
|
333
|
+
return `[Vision Error] ${msg}`;
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
337
|
+
file_tool: tool({
|
|
338
|
+
description: DESC.file_tool[LANG],
|
|
339
|
+
args: { command: tool.schema.string().describe(DESC.file_tool_args[LANG]) },
|
|
340
|
+
execute: async ({ command }, context) => {
|
|
341
|
+
const cmd = command.trim();
|
|
342
|
+
if (cmd === "list-provider") {
|
|
343
|
+
const cfg = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {};
|
|
344
|
+
const models = [];
|
|
345
|
+
const oc = JSON.parse(readFileSync(OPENCODE_CONFIG, "utf-8"));
|
|
346
|
+
for (const [pName, pVal] of Object.entries(oc.provider || {}))
|
|
347
|
+
for (const mId of Object.keys(pVal.models || {}))
|
|
348
|
+
models.push(`${pName}/${mId}`);
|
|
349
|
+
return T("current_model", {
|
|
350
|
+
model: cfg.model || T("model_not_set"),
|
|
351
|
+
list: models.map(m => " " + m).join("\n"),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if (cmd.startsWith("set-provider ")) {
|
|
355
|
+
const model = cmd.slice(13).trim();
|
|
356
|
+
if (!model)
|
|
357
|
+
return T("specify_model");
|
|
358
|
+
const cfg = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {};
|
|
359
|
+
cfg.model = model;
|
|
360
|
+
delete cfg.apiKey;
|
|
361
|
+
delete cfg.apiBaseUrl;
|
|
362
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
363
|
+
reloadCfg();
|
|
364
|
+
return T("model_switched", { model });
|
|
365
|
+
}
|
|
366
|
+
if (cmd === "list-cache" || cmd.startsWith("list-cache ")) {
|
|
367
|
+
const arg = cmd === "list-cache" ? "1" : cmd.slice(11).trim();
|
|
368
|
+
let targetSid = context.sessionID;
|
|
369
|
+
let limit = arg;
|
|
370
|
+
if (arg === "main") {
|
|
371
|
+
targetSid = getRootSession(context.sessionID);
|
|
372
|
+
limit = "1";
|
|
373
|
+
}
|
|
374
|
+
if (arg.startsWith("main ")) {
|
|
375
|
+
targetSid = getRootSession(context.sessionID);
|
|
376
|
+
limit = arg.slice(5).trim();
|
|
377
|
+
}
|
|
378
|
+
const data = readSession(targetSid);
|
|
379
|
+
const msgs = data.messages || [];
|
|
380
|
+
if (msgs.length === 0)
|
|
381
|
+
return `${targetSid}: ${T("no_cache")}`;
|
|
382
|
+
let count = msgs.length;
|
|
383
|
+
if (limit !== "all") {
|
|
384
|
+
const n = parseInt(limit, 10);
|
|
385
|
+
if (!isNaN(n) && n > 0)
|
|
386
|
+
count = Math.min(n, count);
|
|
387
|
+
}
|
|
388
|
+
const show = msgs.slice(-count);
|
|
389
|
+
let out = `${targetSid}:\n`;
|
|
390
|
+
for (const msg of show) {
|
|
391
|
+
out += ` msg_${msg.msgId.slice(-8)}:\n`;
|
|
392
|
+
for (const fid of msg.fileIds) {
|
|
393
|
+
const f = data.files[fid];
|
|
394
|
+
if (f)
|
|
395
|
+
out += ` ${f.filename}: ${f.id}\n`;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return out.trim();
|
|
399
|
+
}
|
|
400
|
+
return T("unknown_cmd", { cmd });
|
|
401
|
+
},
|
|
402
|
+
}),
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
};
|
package/package.json
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiaoqiong0v0/opencode-file-tool",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "File cache & image analysis plugin for OpenCode. Auto-caches pasted images, analyzes via multimodal model.",
|
|
6
6
|
"files": [
|
|
7
|
-
"
|
|
7
|
+
"dist",
|
|
8
8
|
"README.md"
|
|
9
9
|
],
|
|
10
|
+
"main": "./dist/file-tool.js",
|
|
10
11
|
"exports": {
|
|
11
|
-
".": "./file-tool.js"
|
|
12
|
+
".": "./dist/file-tool.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc && node -e \"require('fs').copyFileSync('dist/file-tool.js', '.opencode/plugins/file-tool.js')\"",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
12
17
|
},
|
|
13
18
|
"repository": {
|
|
14
19
|
"type": "git",
|
|
@@ -20,6 +25,11 @@
|
|
|
20
25
|
},
|
|
21
26
|
"license": "MIT",
|
|
22
27
|
"dependencies": {
|
|
23
|
-
"@xiaoqiong0v0/opencode-plugin-logger": "^1.0.
|
|
28
|
+
"@xiaoqiong0v0/opencode-plugin-logger": "^1.0.2"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@opencode-ai/plugin": "^1.18.4",
|
|
32
|
+
"@types/node": "^26.1.1",
|
|
33
|
+
"typescript": "^7.0.2"
|
|
24
34
|
}
|
|
25
35
|
}
|
package/file-tool.js
DELETED
|
@@ -1,397 +0,0 @@
|
|
|
1
|
-
import { tool } from "@opencode-ai/plugin"
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"
|
|
3
|
-
import createLogger from "@xiaoqiong0v0/opencode-plugin-logger"
|
|
4
|
-
|
|
5
|
-
import { rm } from "node:fs/promises"
|
|
6
|
-
import { join } from "node:path"
|
|
7
|
-
|
|
8
|
-
const CONFIG_DIR = process.env.HOME || process.env.USERPROFILE
|
|
9
|
-
const CONFIG_PATH = join(CONFIG_DIR, ".config/opencode/file-tool.jsonc")
|
|
10
|
-
const OPENCODE_CONFIG = join(CONFIG_DIR, ".config/opencode/opencode.json")
|
|
11
|
-
const CACHE_DIR = join(CONFIG_DIR, ".opencode/plugins-cache")
|
|
12
|
-
const CMD_DIR = join(CONFIG_DIR, ".config/opencode/command")
|
|
13
|
-
|
|
14
|
-
const log = createLogger("file-tool")
|
|
15
|
-
|
|
16
|
-
// === 全局配置 ===
|
|
17
|
-
let _cfg = null
|
|
18
|
-
const FILE_TOOL_CFG_SAMPLE = `{
|
|
19
|
-
// 视觉分析模型(provider/modelId),file_tool set-provider 切换
|
|
20
|
-
"model": "",
|
|
21
|
-
"maxTokens": 4096,
|
|
22
|
-
"timeout": 60000,
|
|
23
|
-
"maxFileSizeMB": 20,
|
|
24
|
-
// 缓存消息数量上限,超过则删除最早的
|
|
25
|
-
"maxCacheMessages": 3,
|
|
26
|
-
// 工具提示语言:zh=中文, en=English
|
|
27
|
-
"lang": "en"
|
|
28
|
-
}
|
|
29
|
-
`
|
|
30
|
-
const CMD_ZH = `---
|
|
31
|
-
description: 切换视觉分析模型
|
|
32
|
-
---
|
|
33
|
-
直接调用 file_tool 工具,不要委托给其他 agent。
|
|
34
|
-
没有参数默认传递:\`list-provider\`,列出可选择模型提供者。
|
|
35
|
-
使用 \`set-provider <模型名>\` 切换模型。
|
|
36
|
-
使用 \`list-cache\` 查看缓存文件列表。
|
|
37
|
-
`
|
|
38
|
-
const CMD_EN = `---
|
|
39
|
-
description: Switch vision analysis model
|
|
40
|
-
---
|
|
41
|
-
Call file_tool directly, don't delegate to other agents.
|
|
42
|
-
Default: \`list-provider\` to list available model providers.
|
|
43
|
-
Use \`set-provider <model>\` to switch models.
|
|
44
|
-
Use \`list-cache\` to view cached files.
|
|
45
|
-
`
|
|
46
|
-
|
|
47
|
-
let MAX_CACHE_MSGS = 3
|
|
48
|
-
let LANG = "en"
|
|
49
|
-
|
|
50
|
-
function loadCfg() {
|
|
51
|
-
if (!existsSync(CONFIG_PATH)) {
|
|
52
|
-
try { writeFileSync(CONFIG_PATH, FILE_TOOL_CFG_SAMPLE, "utf-8") } catch {}
|
|
53
|
-
}
|
|
54
|
-
const raw = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {}
|
|
55
|
-
_cfg = resolveConfig(raw)
|
|
56
|
-
MAX_CACHE_MSGS = (raw.maxCacheMessages > 0) ? raw.maxCacheMessages : 3
|
|
57
|
-
LANG = raw.lang || "en"
|
|
58
|
-
// 自动生成 command 定义
|
|
59
|
-
const cmdLang = raw.lang || "en"
|
|
60
|
-
const content = cmdLang === "en" ? CMD_EN : CMD_ZH
|
|
61
|
-
if (!existsSync(CMD_DIR)) mkdirSync(CMD_DIR, { recursive: true })
|
|
62
|
-
const cmdFile = join(CMD_DIR, "file-tool.md")
|
|
63
|
-
if (!existsSync(cmdFile)) {
|
|
64
|
-
writeFileSync(cmdFile, content, "utf-8")
|
|
65
|
-
} else {
|
|
66
|
-
const existing = readFileSync(cmdFile, "utf-8")
|
|
67
|
-
if (existing === CMD_ZH || existing === CMD_EN) {
|
|
68
|
-
if (existing !== content) writeFileSync(cmdFile, content, "utf-8")
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return _cfg
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function reloadCfg() { loadCfg() }
|
|
75
|
-
|
|
76
|
-
loadCfg()
|
|
77
|
-
|
|
78
|
-
const TX = {
|
|
79
|
-
file_not_found: { zh: "文件不存在: {path}", en: "File not found: {path}" },
|
|
80
|
-
file_id_not_found: { zh: "文件ID不存在: {id}", en: "File ID not found: {id}" },
|
|
81
|
-
file_data_not_found: { zh: "文件数据不存在: {id}", en: "File data not found: {id}" },
|
|
82
|
-
not_an_image: { zh: "不是图片文件: {name} ({mime})", en: "Not an image: {name} ({mime})" },
|
|
83
|
-
unsupported_source: { zh: "不支持的图片来源: {source}", en: "Unsupported source: {source}" },
|
|
84
|
-
describe_image: { zh: "请详细描述这张图片({name})的内容", en: "Describe this image ({name})" },
|
|
85
|
-
current_model: { zh: "当前模型: {model}\n可用模型:\n{list}", en: "Current model: {model}\nAvailable models:\n{list}" },
|
|
86
|
-
model_not_set: { zh: "未设置", en: "not set" },
|
|
87
|
-
model_switched: { zh: "视觉模型已切换为: {model}", en: "Vision model set to: {model}" },
|
|
88
|
-
specify_model: { zh: "请指定模型名", en: "Specify a model name" },
|
|
89
|
-
unknown_cmd: { zh: "未知命令: {cmd}\n可用: list-provider, set-provider <model>, list-cache [all|N|main|main N]", en: "Unknown command: {cmd}\nAvailable: list-provider, set-provider <model>, list-cache [all|N|main|main N]" },
|
|
90
|
-
config_error: { zh: "请在 file-tool.jsonc 中配置 model (provider/modelId) 或 apiKey+apiBaseUrl+model", en: "Set model (provider/modelId) or apiKey+apiBaseUrl+model in file-tool.jsonc" },
|
|
91
|
-
meta_failed: { zh: "分析失败", en: "Failed" },
|
|
92
|
-
meta_skip: { zh: "跳过", en: "Skip" },
|
|
93
|
-
meta_not_found: { zh: "文件不存在", en: "Not found" },
|
|
94
|
-
meta_image: { zh: "图片", en: "Image" },
|
|
95
|
-
meta_error: { zh: "分析出错", en: "Error" },
|
|
96
|
-
no_cache: { zh: "[] (无缓存)", en: "[] (no cache)" },
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const T = (key, params) => {
|
|
100
|
-
const t = (TX[key] || { zh: key, en: key })[LANG]
|
|
101
|
-
if (!params) return t
|
|
102
|
-
return Object.entries(params).reduce((s, [k, v]) => s.replace(`{${k}}`, v), t)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const DESC = {
|
|
106
|
-
analyze_image: {
|
|
107
|
-
zh: "用多模态模型分析图片。先调 file_tool list-cache 拿到文件ID,再用 file_id:N 分析。",
|
|
108
|
-
en: "Analyze images with multimodal model. Call file_tool list-cache first to get file IDs, then use file_id:N.",
|
|
109
|
-
},
|
|
110
|
-
file_tool: {
|
|
111
|
-
zh: "文件缓存管理。当你在上下文中看到 [Image N] 或收到 Cannot read 图片错误时,立即调 list-cache 获取文件ID,再用 analyze_image file_id:N 分析。",
|
|
112
|
-
en: "File cache manager. When you see [Image N] or a Cannot read image error, call list-cache to get file IDs, then use analyze_image file_id:N.",
|
|
113
|
-
},
|
|
114
|
-
file_tool_args: {
|
|
115
|
-
zh: "list-cache, list-cache main, list-provider, set-provider <model>",
|
|
116
|
-
en: "list-cache, list-cache main, list-provider, set-provider <model>",
|
|
117
|
-
},
|
|
118
|
-
analyze_args_source: { zh: "file_path=file_id:N", en: "file_path=file_id:N" },
|
|
119
|
-
analyze_args_data: { zh: "file_id:N 或 base64", en: "file_id:N or base64" },
|
|
120
|
-
analyze_args_prompt: { zh: "分析提示", en: "prompt" },
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function getCfg() {
|
|
124
|
-
if (_cfg) return _cfg
|
|
125
|
-
const raw = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {}
|
|
126
|
-
_cfg = resolveConfig(raw)
|
|
127
|
-
return _cfg
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function resolveConfig(fileConfig) {
|
|
131
|
-
const model = fileConfig.model
|
|
132
|
-
if (!model) throw new Error(T("config_error"))
|
|
133
|
-
if (fileConfig.apiKey && fileConfig.apiBaseUrl) {
|
|
134
|
-
const mId = model.includes("/") ? model.split("/").pop() : model
|
|
135
|
-
return { apiKey: fileConfig.apiKey, baseURL: fileConfig.apiBaseUrl, modelId: mId, maxTokens: fileConfig.maxTokens || 4096, timeout: fileConfig.timeout || 60000 }
|
|
136
|
-
}
|
|
137
|
-
if (model.includes("/")) {
|
|
138
|
-
const [provider, modelId] = model.split("/")
|
|
139
|
-
try {
|
|
140
|
-
const raw = readFileSync(OPENCODE_CONFIG, "utf-8")
|
|
141
|
-
const oc = JSON.parse(raw)
|
|
142
|
-
const prov = oc.provider?.[provider]
|
|
143
|
-
if (prov?.options?.apiKey && prov?.options?.baseURL)
|
|
144
|
-
return { apiKey: prov.options.apiKey, baseURL: prov.options.baseURL, modelId, maxTokens: fileConfig.maxTokens || 4096, timeout: fileConfig.timeout || 60000 }
|
|
145
|
-
} catch {}
|
|
146
|
-
}
|
|
147
|
-
throw new Error(`无法解析模型配置: ${model}。请在 file-tool.jsonc 中配置 model (provider/modelId) 或 apiKey+apiBaseUrl+model`)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function readJsonc(path) {
|
|
151
|
-
const raw = readFileSync(path, "utf-8").replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "")
|
|
152
|
-
return JSON.parse(raw)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function callVisionApi(imageUrl, prompt) {
|
|
156
|
-
const cfg = getCfg()
|
|
157
|
-
const resp = await fetch(`${cfg.baseURL}/chat/completions`, {
|
|
158
|
-
method: "POST",
|
|
159
|
-
headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
|
|
160
|
-
body: JSON.stringify({ model: cfg.modelId, messages: [{ role: "user", content: [{ type: "text", text: prompt || "请详细描述这张图片的内容,返回格式: [文件名] 描述" }, { type: "image_url", image_url: { url: imageUrl } }] }], max_tokens: cfg.maxTokens }),
|
|
161
|
-
signal: AbortSignal.timeout(cfg.timeout),
|
|
162
|
-
})
|
|
163
|
-
if (!resp.ok) throw new Error(`API ${resp.status}: ${(await resp.text().catch(() => "unknown")).slice(0, 200)}`)
|
|
164
|
-
const data = await resp.json()
|
|
165
|
-
const msg = data.choices?.[0]?.message
|
|
166
|
-
return msg?.content || msg?.reasoning_content || "(空)"
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ====== 会话栈 ======
|
|
170
|
-
const SessionStack = {
|
|
171
|
-
_stack: ["default"],
|
|
172
|
-
_main: "default",
|
|
173
|
-
push(id) {
|
|
174
|
-
if (this._stack.length === 1 && this._stack[0] === "default") {
|
|
175
|
-
this._main = id
|
|
176
|
-
}
|
|
177
|
-
this._stack.push(id)
|
|
178
|
-
},
|
|
179
|
-
remove(id) {
|
|
180
|
-
const idx = this._stack.indexOf(id)
|
|
181
|
-
if (idx >= 0) this._stack.splice(idx)
|
|
182
|
-
if (this._stack.length === 0) this._stack.push("default")
|
|
183
|
-
},
|
|
184
|
-
get current() { return this._stack[this._stack.length - 1] },
|
|
185
|
-
get main() {
|
|
186
|
-
try { const v = readFileSync(join(CACHE_DIR, ".main-session"), "utf-8").trim(); if (v) return v } catch {}
|
|
187
|
-
return this._main && this._main !== "default" ? this._main : "default"
|
|
188
|
-
},
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ====== 文件缓存:~/.opencode/plugins-cache/{sessionId}/files.json ======
|
|
192
|
-
function sessionDir(sid) { return join(CACHE_DIR, sid) }
|
|
193
|
-
|
|
194
|
-
function filesDir(sid) { const d = join(sessionDir(sid), "files"); if (!existsSync(d)) mkdirSync(d, { recursive: true }); return d }
|
|
195
|
-
|
|
196
|
-
function readSession(sid) {
|
|
197
|
-
try { return JSON.parse(readFileSync(join(sessionDir(sid), "files.json"), "utf-8")) }
|
|
198
|
-
catch { return { nextId: 1, files: {}, messages: [] } }
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function writeSession(sid, data) {
|
|
202
|
-
const dir = sessionDir(sid)
|
|
203
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
204
|
-
// 超出上限时异步删除最早的消息及文件
|
|
205
|
-
const msgs = data.messages || []
|
|
206
|
-
if (msgs.length > MAX_CACHE_MSGS) {
|
|
207
|
-
const expired = msgs.splice(0, msgs.length - MAX_CACHE_MSGS)
|
|
208
|
-
for (const msg of expired) {
|
|
209
|
-
for (const fid of (msg.fileIds || [])) {
|
|
210
|
-
delete data.files[fid]
|
|
211
|
-
const path = join(dir, "files", fid + ".b64")
|
|
212
|
-
rm(path, { force: true })
|
|
213
|
-
.then(() => {
|
|
214
|
-
log.info(`${sid}: Deleted file ${path}`)
|
|
215
|
-
})
|
|
216
|
-
.catch((err) => {
|
|
217
|
-
log.error(`${sid}: Failed to delete file ${path}`, err)
|
|
218
|
-
})
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
writeFileSync(join(dir, "files.json"), JSON.stringify(data, null, 2))
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function writeFileData(sid, fid, url) {
|
|
226
|
-
// url 格式: "data:image/png;base64,iVBOR...",只存 base64 部分
|
|
227
|
-
const b64 = url.replace(/^data:\w+\/\w+;base64,/, "")
|
|
228
|
-
writeFileSync(join(filesDir(sid), fid + ".b64"), b64, "utf-8")
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function readFileData(sid, fid) {
|
|
232
|
-
try {
|
|
233
|
-
const b64 = readFileSync(join(filesDir(sid), fid + ".b64"), "utf-8")
|
|
234
|
-
const meta = readSession(sid).files[fid]
|
|
235
|
-
return `data:${meta?.mime || "image/png"};base64,${b64}`
|
|
236
|
-
} catch {
|
|
237
|
-
// 当前会话没有,尝试主会话
|
|
238
|
-
try {
|
|
239
|
-
const mainSid = SessionStack.main
|
|
240
|
-
if (mainSid !== sid) return readFileData(mainSid, fid)
|
|
241
|
-
} catch {}
|
|
242
|
-
return null
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function deleteSession(sid) {
|
|
247
|
-
const dir = sessionDir(sid)
|
|
248
|
-
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
export const FileTool = async () => {
|
|
253
|
-
log.loaded()
|
|
254
|
-
return {
|
|
255
|
-
event: async ({ event }) => {
|
|
256
|
-
if (event.type === "session.created" && event.properties?.sessionID)
|
|
257
|
-
SessionStack.push(event.properties.sessionID)
|
|
258
|
-
if (event.type === "session.deleted" && event.properties?.sessionID) {
|
|
259
|
-
deleteSession(event.properties.sessionID)
|
|
260
|
-
SessionStack.remove(event.properties.sessionID)
|
|
261
|
-
}
|
|
262
|
-
if (event.type === "message.part.updated" && event.properties?.part?.type === "file" && (event.properties.part.mime || "").startsWith("image/")) {
|
|
263
|
-
const part = event.properties.part
|
|
264
|
-
const fn = part.filename || part.name || ""
|
|
265
|
-
if (fn) {
|
|
266
|
-
const sid = event.properties.sessionID || SessionStack.current
|
|
267
|
-
// 首次获取到真实会话ID时更新栈并记录主会话ID
|
|
268
|
-
if (sid && SessionStack.current === "default" && sid !== "default") {
|
|
269
|
-
SessionStack._stack = [sid]
|
|
270
|
-
SessionStack._main = sid
|
|
271
|
-
try { writeFileSync(join(CACHE_DIR, ".main-session"), sid, "utf-8") } catch {}
|
|
272
|
-
}
|
|
273
|
-
// 首次贴图也记录主会话ID(适配主会话未触发session.created的场景)
|
|
274
|
-
if (sid && !existsSync(join(CACHE_DIR, ".main-session"))) {
|
|
275
|
-
try { writeFileSync(join(CACHE_DIR, ".main-session"), sid, "utf-8") } catch {}
|
|
276
|
-
}
|
|
277
|
-
const data = readSession(sid)
|
|
278
|
-
const fid = data.nextId++
|
|
279
|
-
const msgId = part.messageID || ""
|
|
280
|
-
// 添加到文件映射
|
|
281
|
-
data.files[fid] = { id: fid, filename: fn, mime: part.mime, msgId }
|
|
282
|
-
writeFileData(sid, fid, part.url || "")
|
|
283
|
-
// 按消息分组
|
|
284
|
-
const msgs = data.messages
|
|
285
|
-
const last = msgs[msgs.length - 1]
|
|
286
|
-
if (last && last.msgId === msgId) {
|
|
287
|
-
last.fileIds.push(fid)
|
|
288
|
-
} else {
|
|
289
|
-
msgs.push({ msgId, fileIds: [fid] })
|
|
290
|
-
}
|
|
291
|
-
writeSession(sid, data)
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
},
|
|
295
|
-
|
|
296
|
-
tool: {
|
|
297
|
-
analyze_image: tool({
|
|
298
|
-
description: DESC.analyze_image[LANG],
|
|
299
|
-
args: {
|
|
300
|
-
source: tool.schema.enum(["file_path", "base64"]).describe(DESC.analyze_args_source[LANG]),
|
|
301
|
-
data: tool.schema.string().describe(DESC.analyze_args_data[LANG]),
|
|
302
|
-
prompt: tool.schema.string().optional().describe(DESC.analyze_args_prompt[LANG]),
|
|
303
|
-
},
|
|
304
|
-
execute: async ({ source, data, prompt }, context) => {
|
|
305
|
-
let imageUrl, fileName = ""
|
|
306
|
-
if (source === "file_path" && data.startsWith("file_id:")) {
|
|
307
|
-
const fid = parseInt(data.slice(8), 10)
|
|
308
|
-
const store = readSession(context.sessionID)
|
|
309
|
-
let file = store.files[fid]
|
|
310
|
-
if (!file && context.sessionID !== SessionStack.main) {
|
|
311
|
-
const mainStore = readSession(SessionStack.main)
|
|
312
|
-
file = mainStore.files[fid]
|
|
313
|
-
}
|
|
314
|
-
if (!file) { context.metadata?.({ title: T("meta_failed") }); return T("file_id_not_found", { id: fid }) }
|
|
315
|
-
if (!file.mime.startsWith("image/")) { context.metadata?.({ title: T("meta_skip") }); return T("not_an_image", { name: file.filename, mime: file.mime }) }
|
|
316
|
-
fileName = file.filename
|
|
317
|
-
imageUrl = readFileData(context.sessionID, fid)
|
|
318
|
-
if (!imageUrl) { context.metadata?.({ title: T("meta_failed") }); return T("file_data_not_found", { id: fid }) }
|
|
319
|
-
prompt = prompt || T("describe_image", { name: fileName })
|
|
320
|
-
} else if (source === "file_path") {
|
|
321
|
-
if (!existsSync(data)) {
|
|
322
|
-
const tryPath = join(context.directory, data)
|
|
323
|
-
if (existsSync(tryPath)) data = tryPath
|
|
324
|
-
}
|
|
325
|
-
if (!existsSync(data)) { context.metadata?.({ title: T("meta_not_found") }); return T("file_not_found", { path: data }) }
|
|
326
|
-
const ext = data.split(".").pop().toLowerCase()
|
|
327
|
-
const mime = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", bmp: "image/bmp", gif: "image/gif", webp: "image/webp" }[ext] || "image/png"
|
|
328
|
-
fileName = data.split(/[/\\]/).pop() || ""
|
|
329
|
-
imageUrl = `data:${mime};base64,${readFileSync(data).toString("base64")}`
|
|
330
|
-
} else if (source === "base64") {
|
|
331
|
-
imageUrl = `data:image/png;base64,${data.replace(/^data:image\/\w+;base64,/, "")}`
|
|
332
|
-
} else { return T("unsupported_source", { source }) }
|
|
333
|
-
try {
|
|
334
|
-
const result = await callVisionApi(imageUrl, prompt)
|
|
335
|
-
context.metadata?.({ title: `[Vision] ${fileName || T("meta_image")}`, metadata: { sessionID: context.sessionID, messageID: context.messageID } })
|
|
336
|
-
return "[Vision] " + result
|
|
337
|
-
} catch (e) { context.metadata?.({ title: T("meta_error") }); return `[Vision Error] ${e.message}` }
|
|
338
|
-
},
|
|
339
|
-
}),
|
|
340
|
-
|
|
341
|
-
file_tool: tool({
|
|
342
|
-
description: DESC.file_tool[LANG],
|
|
343
|
-
args: { command: tool.schema.string().describe(DESC.file_tool_args[LANG]) },
|
|
344
|
-
execute: async ({ command }, context) => {
|
|
345
|
-
const cmd = command.trim()
|
|
346
|
-
if (cmd === "list-provider") {
|
|
347
|
-
const cfg = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {}
|
|
348
|
-
const models = []
|
|
349
|
-
const oc = JSON.parse(readFileSync(OPENCODE_CONFIG, "utf-8"))
|
|
350
|
-
for (const [pName, pVal] of Object.entries(oc.provider || {}))
|
|
351
|
-
for (const mId of Object.keys(pVal.models || {}))
|
|
352
|
-
models.push(`${pName}/${mId}`)
|
|
353
|
-
return T("current_model", {
|
|
354
|
-
model: cfg.model || T("model_not_set"),
|
|
355
|
-
list: models.map(m => " " + m).join("\n"),
|
|
356
|
-
})
|
|
357
|
-
}
|
|
358
|
-
if (cmd.startsWith("set-provider ")) {
|
|
359
|
-
const model = cmd.slice(13).trim()
|
|
360
|
-
if (!model) return T("specify_model")
|
|
361
|
-
const cfg = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {}
|
|
362
|
-
cfg.model = model; delete cfg.apiKey; delete cfg.apiBaseUrl
|
|
363
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2))
|
|
364
|
-
reloadCfg()
|
|
365
|
-
return T("model_switched", { model })
|
|
366
|
-
}
|
|
367
|
-
if (cmd === "list-cache" || cmd.startsWith("list-cache ")) {
|
|
368
|
-
const arg = cmd === "list-cache" ? "1" : cmd.slice(11).trim()
|
|
369
|
-
let targetSid = context.sessionID
|
|
370
|
-
let limit = arg
|
|
371
|
-
if (arg === "main") { targetSid = SessionStack.main; limit = "1" }
|
|
372
|
-
if (arg.startsWith("main ")) { targetSid = SessionStack.main; limit = arg.slice(5).trim() }
|
|
373
|
-
const data = readSession(targetSid)
|
|
374
|
-
const msgs = data.messages || []
|
|
375
|
-
if (msgs.length === 0) return `${targetSid}: ${T("no_cache")}`
|
|
376
|
-
let count = msgs.length
|
|
377
|
-
if (limit !== "all") {
|
|
378
|
-
const n = parseInt(limit, 10)
|
|
379
|
-
if (!isNaN(n) && n > 0) count = Math.min(n, count)
|
|
380
|
-
}
|
|
381
|
-
const show = msgs.slice(-count)
|
|
382
|
-
let out = `${targetSid}:\n`
|
|
383
|
-
for (const msg of show) {
|
|
384
|
-
out += ` msg_${msg.msgId.slice(-8)}:\n`
|
|
385
|
-
for (const fid of msg.fileIds) {
|
|
386
|
-
const f = data.files[fid]
|
|
387
|
-
if (f) out += ` ${f.filename}: ${f.id}\n`
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
return out.trim()
|
|
391
|
-
}
|
|
392
|
-
return T("unknown_cmd", { cmd })
|
|
393
|
-
},
|
|
394
|
-
}),
|
|
395
|
-
},
|
|
396
|
-
}
|
|
397
|
-
}
|