@xiaoqiong0v0/opencode-file-tool 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/file-tool.js CHANGED
@@ -1,9 +1,6 @@
1
- import { T } from "./i18n.js";
2
1
  import { log, ENABLED, loadCfg } from "./config.js";
3
2
  import { registerInputFile, migrateLegacyCache, knownSessions, sessionParents, deleteSession, removeMsgCache, extForMime } from "./cache.js";
4
- import { analyzeImageTool } from "./tools/analyze.js";
5
- import { textToImageTool, textToVideoTool, textToSpeechTool } from "./tools/generate.js";
6
- import { fileTool } from "./tools/file_tool.js";
3
+ import { fileToolCli } from "./tools/cli.js";
7
4
  try {
8
5
  loadCfg();
9
6
  }
@@ -13,11 +10,6 @@ catch (e) {
13
10
  export const fileToolPlugin = async () => {
14
11
  log.loaded();
15
12
  return {
16
- config: async (config) => {
17
- const commands = config.command ?? {};
18
- commands["file-tool"] = { template: T("cmd_template"), description: T("cmd_desc") };
19
- config.command = commands;
20
- },
21
13
  event: async ({ event }) => {
22
14
  const props = event.properties;
23
15
  const sid = props?.sessionID;
@@ -71,11 +63,7 @@ export const fileToolPlugin = async () => {
71
63
  }
72
64
  },
73
65
  tool: {
74
- analyze_image: analyzeImageTool,
75
- text_to_image: textToImageTool,
76
- text_to_video: textToVideoTool,
77
- text_to_speech: textToSpeechTool,
78
- file_tool: fileTool,
66
+ file_tool: fileToolCli,
79
67
  },
80
68
  };
81
69
  };
package/dist/i18n.js CHANGED
@@ -40,23 +40,10 @@ export const TX = {
40
40
  empty_response: { zh: "(空)", en: "(empty)" },
41
41
  uncached: { zh: "未缓存", en: "uncached" },
42
42
  uncached_hint: { zh: "文件未缓存(id={id}),请先启用缓存再操作", en: "File not cached (id={id}), enable cache first" },
43
- cmd_desc: { zh: "文件缓存管理 + 多模型配置(视觉/文生图/文生视频/文生语音)", en: "File cache manager + multi-model config (vision/image/video/tts)" },
44
- cmd_template: { zh: "直接调用 file_tool 工具。`list-provider` 列出模型(优先 API 查询),`set-provider [类型:]模型名` 切换模型(类型: vision/image/video/tts),`list-cache [类型] [数量]` 查看缓存,`enable/disable` 临时开关,`enable-save/disable-save` 持久化开关,`status` 查看状态。", en: "Call file_tool tool directly. `list-provider` list models (API-first), `set-provider [type:]model` switch (type: vision/image/video/tts), `list-cache [type] [count]` view cache, `enable/disable` temp toggle, `enable-save/disable-save` persist toggle, `status` show state." },
45
43
  };
46
44
  export const DESC = {
47
- analyze_image: { zh: "用多模态模型分析图片。先调 file_tool list-cache 拿到文件ID,再用 file_id:类型:id 分析。", en: "Analyze images with multimodal model. Call file_tool list-cache first to get file IDs, then use file_id:type:id." },
48
- text_to_image: { zh: "文生图:根据文本提示生成图片,结果缓存并返回 file_id:类型:id,可用 analyze_image 查看。", en: "Text-to-image: generate an image from a prompt, cached and returned as file_id:type:id." },
49
- text_to_video: { zh: "文生视频:根据文本提示生成视频(异步提交+轮询),结果缓存并返回 file_id:类型:id。", en: "Text-to-video: generate a video from a prompt (async submit+poll), cached and returned as file_id:type:id." },
50
- text_to_speech: { zh: "文生语音:将文本转为语音(TTS),结果缓存并返回 file_id:类型:id。", en: "Text-to-speech: convert text to audio, cached and returned as file_id:type:id." },
51
- file_tool: { zh: "文件缓存管理。当你在上下文中看到 [Image N] 或收到 Cannot read 图片错误时,立即调 list-cache 获取文件ID,再用 analyze_image file_id:类型:id 分析。主模型能直接读取图片时建议用 `disable` 关闭缓存。", 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:type:id. If the main model can read images directly, use `disable` to turn off caching." },
52
- file_tool_args: { zh: "list-cache [类型] [数量](类型: input/image/video/tts,如 list-cache image 3),list-provider, set-provider [<类型>:]<模型名>, enable/disable(临时), enable-save/disable-save(持久化), status — main 前缀查主会话(list-cache main [类型] [数量])", en: "list-cache [type] [count] (type: input/image/video/tts, e.g. list-cache image 3), list-provider, set-provider [<type>:]<model>, enable/disable (temp), enable-save/disable-save (persist), status — main prefix for root session (list-cache main [type] [count])" },
53
- analyze_args_source: { zh: "file_path=file_id:类型:id(如 file_id:image:2)", en: "file_path=file_id:type:id (e.g. file_id:image:2)" },
54
- analyze_args_data: { zh: "file_id:类型:id 或 base64", en: "file_id:type:id or base64" },
55
- analyze_args_prompt: { zh: "分析提示", en: "prompt" },
56
- gen_args_prompt: { zh: "生成提示词", en: "generation prompt" },
57
- gen_args_size: { zh: "图片尺寸(如 1024x1024)", en: "image size (e.g. 1024x1024)" },
58
- gen_args_duration: { zh: "视频时长(秒)", en: "video duration (seconds)" },
59
- gen_args_voice: { zh: "音色(如 alloy)", en: "voice (e.g. alloy)" },
45
+ file_tool: { zh: "统一命令行工具。子命令: analyze 分析图 / imagine 文生图 / video 文生视频 / tts 文生语音 / list 缓存列表 / providers 模型列表 / set 切换模型 / status / enable-disable。空参数或 help 看用法。", en: "Single CLI tool. Subcommands: analyze image / imagine t2i / video t2v / tts / list cache / providers models / set model / status / enable-disable. Empty or help for usage." },
46
+ file_tool_args: { zh: "完整命令行字符串,如 'list input'、'imagine 一只橘猫'、'analyze file_id:input:1';空时默认 help。类型: input=用户图片, image/video/tts=生成产物。", en: "Full command string, e.g. 'list input', 'imagine a cat', 'analyze file_id:input:1'; empty defaults to help. Types: input=user image, image/video/tts=generated." },
60
47
  };
61
48
  export function T(key, params) {
62
49
  const entry = TX[key] || { zh: key, en: key };
@@ -0,0 +1,342 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import stringArgv from "string-argv";
5
+ import { parseArgs } from "node:util";
6
+ import { T, LANG, DESC } from "../i18n.js";
7
+ import { CONFIG_PATH, MODEL_TYPES, CACHE_TYPES, DEFAULT_CFG, getCfg, getProviderNames, readJsonc, ENABLED, setEnabled, saveCfg, requireModelCfg } from "../config.js";
8
+ import { readTypeStore, getRootSession, findFileInChain, readFileData, registerGeneratedFile, fetchToBuffer, buildGenFilename, extForMime } from "../cache.js";
9
+ import { generateVideo } from "../providers.js";
10
+ import { genError } from "../utils.js";
11
+ import { localModelList, listModelsApi, currentModelSummary } from "./file_tool_helpers.js";
12
+ const GEN_TIMEOUT = 300000;
13
+ function helpText() {
14
+ if (LANG === "zh") {
15
+ return `用法: file_tool <子命令> [参数]
16
+ 子命令:
17
+ analyze <file_id:类型:id|路径|base64> [提示语] 分析图片
18
+ imagine <提示词> [--size 1024x1024] 文生图
19
+ video <提示词> [--duration 5] 文生视频(异步)
20
+ tts <文本> [--voice alloy] 文生语音
21
+ list [类型] [数量] 缓存列表 (input=用户图/image/video/tts=生成)
22
+ providers 列出可用模型
23
+ set [类型:]模型名 切换模型
24
+ status 查看状态
25
+ enable|disable|enable-save|disable-save 缓存开关
26
+ help 此帮助
27
+ 示例:
28
+ file_tool analyze file_id:input:1
29
+ file_tool imagine "一只橘猫" --size 512x512
30
+ file_tool list input
31
+ file_tool set image:agnes/agnes-image-2.1-flash`;
32
+ }
33
+ return `Usage: file_tool <subcommand> [args]
34
+ Subcommands:
35
+ analyze <file_id:type:id|path|base64> [prompt] analyze image
36
+ imagine <prompt> [--size 1024x1024] text-to-image
37
+ video <prompt> [--duration 5] text-to-video (async)
38
+ tts <text> [--voice alloy] text-to-speech
39
+ list [type] [count] list cache (input=user image/image/video/tts=generated)
40
+ providers list available models
41
+ set [type:]model switch model
42
+ status show state
43
+ enable|disable|enable-save|disable-save cache toggle
44
+ help this help
45
+ Examples:
46
+ file_tool analyze file_id:input:1
47
+ file_tool imagine "an orange cat" --size 512x512
48
+ file_tool list input
49
+ file_tool set image:agnes/agnes-image-2.1-flash`;
50
+ }
51
+ async function analyzeCmd(args, context) {
52
+ const { positionals } = parseArgs({ args, allowPositionals: true });
53
+ let data = positionals[0] || "";
54
+ let prompt = positionals.slice(1).join(" ") || "";
55
+ if (!data)
56
+ return `${T("specify_model")}\n\n${helpText()}`;
57
+ let imageUrl, fileName = "";
58
+ if (data.startsWith("file_id:")) {
59
+ const segs = data.slice(8).split(":");
60
+ let type = "input";
61
+ let fid;
62
+ if (segs.length === 2 && CACHE_TYPES.includes(segs[0])) {
63
+ type = segs[0];
64
+ fid = parseInt(segs[1], 10);
65
+ }
66
+ else {
67
+ fid = parseInt(segs[0], 10);
68
+ }
69
+ const found = findFileInChain(context.sessionID, type, fid);
70
+ if (!found) {
71
+ context.metadata?.({ title: T("meta_failed") });
72
+ return T("file_id_not_found", { id: `${type}:${fid}` });
73
+ }
74
+ const file = found.file;
75
+ if (!file.cached) {
76
+ context.metadata?.({ title: T("meta_skip") });
77
+ return T("uncached_hint", { id: `${type}:${fid}` });
78
+ }
79
+ if (!file.mime.startsWith("image/")) {
80
+ context.metadata?.({ title: T("meta_skip") });
81
+ return T("not_an_image", { name: file.filename, mime: file.mime });
82
+ }
83
+ fileName = file.filename;
84
+ imageUrl = readFileData(context.sessionID, type, fid) || "";
85
+ if (!imageUrl) {
86
+ context.metadata?.({ title: T("meta_failed") });
87
+ return T("file_data_not_found", { id: `${type}:${fid}` });
88
+ }
89
+ prompt = prompt || T("describe_image", { name: fileName });
90
+ }
91
+ else if (existsSync(data) || existsSync(join(context.directory, data))) {
92
+ if (!existsSync(data))
93
+ data = join(context.directory, data);
94
+ if (!existsSync(data)) {
95
+ context.metadata?.({ title: T("meta_not_found") });
96
+ return T("file_not_found", { path: data });
97
+ }
98
+ const ext = data.split(".").pop()?.toLowerCase() || "";
99
+ const mimeMap = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", bmp: "image/bmp", gif: "image/gif", webp: "image/webp" };
100
+ const mime = mimeMap[ext] || "image/png";
101
+ fileName = data.split(/[/\\]/).pop() || "";
102
+ imageUrl = `data:${mime};base64,${readFileSync(data).toString("base64")}`;
103
+ }
104
+ else {
105
+ imageUrl = `data:image/png;base64,${data.replace(/^data:image\/\w+;base64,/, "")}`;
106
+ }
107
+ try {
108
+ const cfg = requireModelCfg("vision");
109
+ const resp = await fetch(`${cfg.baseURL}/chat/completions`, {
110
+ method: "POST",
111
+ headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
112
+ 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: getCfg().maxTokens }),
113
+ signal: AbortSignal.timeout(getCfg().timeout || 60000),
114
+ });
115
+ if (!resp.ok)
116
+ throw new Error(T("err_api", { status: String(resp.status), msg: (await resp.text().catch(() => "unknown")).slice(0, 200) }));
117
+ const d = await resp.json();
118
+ const msg = d.choices?.[0]?.message;
119
+ const result = msg?.content || msg?.reasoning_content || T("empty_response");
120
+ context.metadata?.({ title: `[Vision] ${fileName || T("meta_image")}`, metadata: { sessionID: context.sessionID, messageID: context.messageID } });
121
+ return "[Vision] " + result;
122
+ }
123
+ catch (e) {
124
+ const msg = e instanceof Error ? e.message : String(e);
125
+ context.metadata?.({ title: T("meta_error") });
126
+ return `[Vision Error] ${msg}`;
127
+ }
128
+ }
129
+ async function imagineCmd(args, context) {
130
+ try {
131
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { size: { type: "string" } } });
132
+ const prompt = positionals.join(" ");
133
+ const size = values.size;
134
+ const cfg = requireModelCfg("image");
135
+ const body = { model: cfg.modelId, prompt };
136
+ if (size)
137
+ body.size = size;
138
+ const resp = await fetch(`${cfg.baseURL}/images/generations`, {
139
+ method: "POST",
140
+ headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
141
+ body: JSON.stringify(body),
142
+ signal: AbortSignal.timeout(GEN_TIMEOUT),
143
+ });
144
+ if (!resp.ok)
145
+ throw new Error(T("err_api", { status: String(resp.status), msg: (await resp.text().catch(() => "unknown")).slice(0, 200) }));
146
+ const data = await resp.json();
147
+ const item = data?.data?.[0];
148
+ if (!item)
149
+ throw new Error(T("gen_no_result"));
150
+ let buffer, mime = "image/png";
151
+ if (item.b64_json)
152
+ buffer = Buffer.from(item.b64_json, "base64");
153
+ else if (item.url) {
154
+ const d = await fetchToBuffer(item.url);
155
+ buffer = d.buffer;
156
+ mime = d.mime;
157
+ }
158
+ else
159
+ throw new Error(T("gen_no_result"));
160
+ const store = readTypeStore(context.sessionID, "image");
161
+ const r = registerGeneratedFile(context.sessionID, "image", buildGenFilename("image", mime, store.nextId), mime, context.messageID, buffer);
162
+ context.metadata?.({ title: "[ImageGen]", metadata: { sessionID: context.sessionID, messageID: context.messageID } });
163
+ return T("image_generated", { name: `image_${r.id}.${extForMime(mime)}`, id: `image:${r.id}`, path: r.path });
164
+ }
165
+ catch (e) {
166
+ context.metadata?.({ title: T("meta_error") });
167
+ return genError(e);
168
+ }
169
+ }
170
+ async function videoCmd(args, context) {
171
+ try {
172
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { duration: { type: "string" } } });
173
+ const prompt = positionals.join(" ");
174
+ const duration = values.duration ? parseInt(values.duration, 10) : undefined;
175
+ const cfg = requireModelCfg("video");
176
+ const url = await generateVideo(cfg, prompt, duration);
177
+ const d = await fetchToBuffer(url);
178
+ const store = readTypeStore(context.sessionID, "video");
179
+ const r = registerGeneratedFile(context.sessionID, "video", buildGenFilename("video", d.mime, store.nextId), d.mime, context.messageID, d.buffer);
180
+ context.metadata?.({ title: "[VideoGen]", metadata: { sessionID: context.sessionID, messageID: context.messageID } });
181
+ return T("video_generated", { name: `video_${r.id}.${extForMime(d.mime)}`, id: `video:${r.id}`, path: r.path });
182
+ }
183
+ catch (e) {
184
+ context.metadata?.({ title: T("meta_error") });
185
+ return genError(e);
186
+ }
187
+ }
188
+ async function ttsCmd(args, context) {
189
+ try {
190
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { voice: { type: "string" } } });
191
+ const text = positionals.join(" ");
192
+ const voice = values.voice;
193
+ const cfg = requireModelCfg("tts");
194
+ const body = { model: cfg.modelId, input: text };
195
+ if (voice)
196
+ body.voice = voice;
197
+ const resp = await fetch(`${cfg.baseURL}/audio/speech`, {
198
+ method: "POST",
199
+ headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
200
+ body: JSON.stringify(body),
201
+ signal: AbortSignal.timeout(GEN_TIMEOUT),
202
+ });
203
+ if (!resp.ok)
204
+ throw new Error(T("err_api", { status: String(resp.status), msg: (await resp.text().catch(() => "unknown")).slice(0, 200) }));
205
+ const buf = Buffer.from(await resp.arrayBuffer());
206
+ const mime = resp.headers.get("content-type")?.split(";")[0] || "audio/mpeg";
207
+ const store = readTypeStore(context.sessionID, "tts");
208
+ const r = registerGeneratedFile(context.sessionID, "tts", buildGenFilename("tts", mime, store.nextId), mime, context.messageID, buf);
209
+ context.metadata?.({ title: "[TTS]", metadata: { sessionID: context.sessionID, messageID: context.messageID } });
210
+ return T("tts_generated", { name: `tts_${r.id}.${extForMime(mime)}`, id: `tts:${r.id}`, path: r.path });
211
+ }
212
+ catch (e) {
213
+ context.metadata?.({ title: T("meta_error") });
214
+ return genError(e);
215
+ }
216
+ }
217
+ async function providersCmd() {
218
+ const providerNames = getProviderNames();
219
+ const models = await listModelsApi(providerNames);
220
+ const fallbackModels = localModelList();
221
+ for (const m of fallbackModels)
222
+ if (!models.includes(m))
223
+ models.push(m);
224
+ const modelLines = models.map(m => " " + m).join("\n");
225
+ return T("current_model", { model: currentModelSummary(), list: modelLines });
226
+ }
227
+ function setCmd(args) {
228
+ const { positionals } = parseArgs({ args, allowPositionals: true });
229
+ const arg = positionals.join(":") || positionals[0] || "";
230
+ if (!arg)
231
+ return T("specify_model");
232
+ let type = "vision";
233
+ let model = arg;
234
+ const colon = arg.indexOf(":");
235
+ if (colon > 0 && MODEL_TYPES.includes(arg.slice(0, colon))) {
236
+ type = arg.slice(0, colon);
237
+ model = arg.slice(colon + 1).trim();
238
+ }
239
+ if (!model)
240
+ return T("specify_model");
241
+ const cfg = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {};
242
+ const models = { ...DEFAULT_CFG.models, ...((cfg.models && typeof cfg.models === "object") ? cfg.models : {}) };
243
+ models[type] = model;
244
+ saveCfg({ models });
245
+ return T("model_switched", { type, model });
246
+ }
247
+ function statusCmd() {
248
+ const c = getCfg();
249
+ const fmt = (t) => c.models[t]?.model || T("model_not_set");
250
+ return T("status", { s: ENABLED ? T("enabled") : T("disabled"), m: fmt("vision"), i: fmt("image"), v: fmt("video"), t: fmt("tts") });
251
+ }
252
+ function listCmd(args, sessionID) {
253
+ const arg = args.join(" ");
254
+ let targetSid = sessionID;
255
+ let rest = arg;
256
+ if (arg === "main" || arg.startsWith("main ")) {
257
+ targetSid = getRootSession(sessionID);
258
+ rest = arg === "main" ? "" : arg.slice(5).trim();
259
+ }
260
+ const tokens = rest.split(/\s+/).filter(Boolean);
261
+ let filter = null;
262
+ let countStr = "1";
263
+ if (tokens[0] && CACHE_TYPES.includes(tokens[0])) {
264
+ filter = tokens[0];
265
+ countStr = tokens[1] || "all";
266
+ }
267
+ else if (tokens[0]) {
268
+ countStr = tokens[0];
269
+ }
270
+ const types = filter ? [filter] : CACHE_TYPES;
271
+ const lines = [];
272
+ for (const type of types) {
273
+ const store = readTypeStore(targetSid, type);
274
+ const entries = Object.values(store.files);
275
+ if (entries.length === 0)
276
+ continue;
277
+ let show = entries;
278
+ if (countStr !== "all") {
279
+ const n = parseInt(countStr, 10);
280
+ if (!isNaN(n) && n > 0)
281
+ show = entries.slice(-n);
282
+ }
283
+ lines.push(` ${type}:`);
284
+ for (const f of show) {
285
+ const src = f.msgId ? `msg_${f.msgId.slice(-8)}` : type === "input" ? "input" : "generated";
286
+ lines.push(` ${f.filename} (${type}:${f.id}) [${src}]`);
287
+ }
288
+ }
289
+ if (lines.length === 0)
290
+ return `${targetSid}: ${T("no_cache")}`;
291
+ return `${targetSid}:\n${lines.join("\n")}`;
292
+ }
293
+ async function handleCommand(raw, context) {
294
+ const tokens = stringArgv(raw);
295
+ const [cmd, ...rest] = tokens;
296
+ if (!cmd)
297
+ return helpText();
298
+ switch (cmd) {
299
+ case "help":
300
+ case "h":
301
+ case "-h":
302
+ case "--help": return helpText();
303
+ case "analyze":
304
+ case "a": return analyzeCmd(rest, context);
305
+ case "imagine":
306
+ case "i": return imagineCmd(rest, context);
307
+ case "video":
308
+ case "v": return videoCmd(rest, context);
309
+ case "tts":
310
+ case "t": return ttsCmd(rest, context);
311
+ case "list":
312
+ case "l": return listCmd(rest, context.sessionID);
313
+ case "providers":
314
+ case "list-provider":
315
+ case "p": return providersCmd();
316
+ case "set":
317
+ case "set-provider":
318
+ case "s": return setCmd(rest);
319
+ case "status": return statusCmd();
320
+ case "enable":
321
+ setEnabled(true);
322
+ return T("enabled");
323
+ case "disable":
324
+ setEnabled(false);
325
+ return T("disabled");
326
+ case "enable-save":
327
+ saveCfg({ enabled: true });
328
+ return T("enabled");
329
+ case "disable-save":
330
+ saveCfg({ enabled: false });
331
+ return T("disabled");
332
+ default:
333
+ return T("unknown_cmd", { cmd: cmd.slice(0, 50) }) + "\n\n" + helpText();
334
+ }
335
+ }
336
+ export const fileToolCli = tool({
337
+ description: DESC.file_tool[LANG],
338
+ args: { command: tool.schema.string().optional().describe(DESC.file_tool_args[LANG]) },
339
+ execute: async ({ command }, context) => {
340
+ return handleCommand(command ?? "help", context);
341
+ },
342
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaoqiong0v0/opencode-file-tool",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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": [
@@ -28,7 +28,8 @@
28
28
  "@opencode-ai/plugin": "*"
29
29
  },
30
30
  "dependencies": {
31
- "@xiaoqiong0v0/opencode-plugin-logger": "^1.0.2"
31
+ "@xiaoqiong0v0/opencode-plugin-logger": "^1.0.4",
32
+ "string-argv": "^0.3.2"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@types/node": "^26.1.1",
@@ -1,94 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin";
2
- import { existsSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { DESC, T, LANG } from "../i18n.js";
5
- import { requireModelCfg, getCfg } from "../config.js";
6
- import { findFileInChain, readFileData } from "../cache.js";
7
- const CACHE_TYPES_LIST = ["input", "image", "video", "tts"];
8
- export const analyzeImageTool = tool({
9
- description: DESC.analyze_image[LANG],
10
- args: {
11
- source: tool.schema.enum(["file_path", "base64"]).describe(DESC.analyze_args_source[LANG]),
12
- data: tool.schema.string().describe(DESC.analyze_args_data[LANG]),
13
- prompt: tool.schema.string().optional().describe(DESC.analyze_args_prompt[LANG]),
14
- },
15
- execute: async ({ source, data, prompt }, context) => {
16
- let imageUrl, fileName = "";
17
- if (source === "file_path" && data.startsWith("file_id:")) {
18
- const segs = data.slice(8).split(":");
19
- let type = "input";
20
- let fid;
21
- if (segs.length === 2 && CACHE_TYPES_LIST.includes(segs[0])) {
22
- type = segs[0];
23
- fid = parseInt(segs[1], 10);
24
- }
25
- else {
26
- fid = parseInt(segs[0], 10);
27
- }
28
- const found = findFileInChain(context.sessionID, type, fid);
29
- if (!found) {
30
- context.metadata?.({ title: T("meta_failed") });
31
- return T("file_id_not_found", { id: `${type}:${fid}` });
32
- }
33
- const file = found.file;
34
- if (!file.cached) {
35
- context.metadata?.({ title: T("meta_skip") });
36
- return T("uncached_hint", { id: `${type}:${fid}` });
37
- }
38
- if (!file.mime.startsWith("image/")) {
39
- context.metadata?.({ title: T("meta_skip") });
40
- return T("not_an_image", { name: file.filename, mime: file.mime });
41
- }
42
- fileName = file.filename;
43
- imageUrl = readFileData(context.sessionID, type, fid) || "";
44
- if (!imageUrl) {
45
- context.metadata?.({ title: T("meta_failed") });
46
- return T("file_data_not_found", { id: `${type}:${fid}` });
47
- }
48
- prompt = prompt || T("describe_image", { name: fileName });
49
- }
50
- else if (source === "file_path") {
51
- if (!existsSync(data)) {
52
- const tryPath = join(context.directory, data);
53
- if (existsSync(tryPath))
54
- data = tryPath;
55
- }
56
- if (!existsSync(data)) {
57
- context.metadata?.({ title: T("meta_not_found") });
58
- return T("file_not_found", { path: data });
59
- }
60
- const ext = data.split(".").pop()?.toLowerCase() || "";
61
- const mimeMap = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", bmp: "image/bmp", gif: "image/gif", webp: "image/webp" };
62
- const mime = mimeMap[ext] || "image/png";
63
- fileName = data.split(/[/\\]/).pop() || "";
64
- imageUrl = `data:${mime};base64,${readFileSync(data).toString("base64")}`;
65
- }
66
- else if (source === "base64") {
67
- imageUrl = `data:image/png;base64,${data.replace(/^data:image\/\w+;base64,/, "")}`;
68
- }
69
- else {
70
- return T("unsupported_source", { source });
71
- }
72
- try {
73
- const cfg = requireModelCfg("vision");
74
- const resp = await fetch(`${cfg.baseURL}/chat/completions`, {
75
- method: "POST",
76
- headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
77
- 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: getCfg().maxTokens }),
78
- signal: AbortSignal.timeout(getCfg().timeout || 60000),
79
- });
80
- if (!resp.ok)
81
- throw new Error(T("err_api", { status: String(resp.status), msg: (await resp.text().catch(() => "unknown")).slice(0, 200) }));
82
- const data = await resp.json();
83
- const msg = data.choices?.[0]?.message;
84
- const result = msg?.content || msg?.reasoning_content || T("empty_response");
85
- context.metadata?.({ title: `[Vision] ${fileName || T("meta_image")}`, metadata: { sessionID: context.sessionID, messageID: context.messageID } });
86
- return "[Vision] " + result;
87
- }
88
- catch (e) {
89
- const msg = e instanceof Error ? e.message : String(e);
90
- context.metadata?.({ title: T("meta_error") });
91
- return `[Vision Error] ${msg}`;
92
- }
93
- },
94
- });
@@ -1,105 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin";
2
- import { existsSync } from "node:fs";
3
- import { DESC, T, LANG } from "../i18n.js";
4
- import { CONFIG_PATH, MODEL_TYPES, CACHE_TYPES, DEFAULT_CFG, getCfg, getProviderNames, readJsonc, ENABLED, setEnabled, saveCfg } from "../config.js";
5
- import { readTypeStore, getRootSession } from "../cache.js";
6
- import { localModelList, listModelsApi, currentModelSummary } from "./file_tool_helpers.js";
7
- export const fileTool = tool({
8
- description: DESC.file_tool[LANG],
9
- args: { command: tool.schema.string().describe(DESC.file_tool_args[LANG]) },
10
- execute: async ({ command }, context) => {
11
- const cmd = command.trim();
12
- if (cmd === "list-provider") {
13
- const providerNames = getProviderNames();
14
- const models = await listModelsApi(providerNames);
15
- const fallbackModels = localModelList();
16
- for (const m of fallbackModels)
17
- if (!models.includes(m))
18
- models.push(m);
19
- const modelLines = models.map(m => " " + m).join("\n");
20
- return T("current_model", { model: currentModelSummary(), list: modelLines });
21
- }
22
- if (cmd.startsWith("set-provider ")) {
23
- const arg = cmd.slice(13).trim();
24
- if (!arg)
25
- return T("specify_model");
26
- let type = "vision";
27
- let model = arg;
28
- const colon = arg.indexOf(":");
29
- if (colon > 0 && MODEL_TYPES.includes(arg.slice(0, colon))) {
30
- type = arg.slice(0, colon);
31
- model = arg.slice(colon + 1).trim();
32
- }
33
- if (!model)
34
- return T("specify_model");
35
- const cfg = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {};
36
- const models = { ...DEFAULT_CFG.models, ...((cfg.models && typeof cfg.models === "object") ? cfg.models : {}) };
37
- models[type] = model;
38
- saveCfg({ models });
39
- return T("model_switched", { type, model });
40
- }
41
- if (cmd === "disable") {
42
- setEnabled(false);
43
- return T("disabled");
44
- }
45
- if (cmd === "enable") {
46
- setEnabled(true);
47
- return T("enabled");
48
- }
49
- if (cmd === "disable-save") {
50
- saveCfg({ enabled: false });
51
- return T("disabled");
52
- }
53
- if (cmd === "enable-save") {
54
- saveCfg({ enabled: true });
55
- return T("enabled");
56
- }
57
- if (cmd === "status") {
58
- const c = getCfg();
59
- const fmt = (t) => c.models[t]?.model || T("model_not_set");
60
- return T("status", { s: ENABLED ? T("enabled") : T("disabled"), m: fmt("vision"), i: fmt("image"), v: fmt("video"), t: fmt("tts") });
61
- }
62
- if (cmd === "list-cache" || cmd.startsWith("list-cache ")) {
63
- const rest = cmd === "list-cache" ? "" : cmd.slice(11).trim();
64
- let targetSid = context.sessionID;
65
- let arg = rest;
66
- if (arg === "main" || arg.startsWith("main ")) {
67
- targetSid = getRootSession(context.sessionID);
68
- arg = arg === "main" ? "" : arg.slice(5).trim();
69
- }
70
- const tokens = arg.split(/\s+/).filter(Boolean);
71
- let filter = null;
72
- let countStr = "1";
73
- if (tokens[0] && CACHE_TYPES.includes(tokens[0])) {
74
- filter = tokens[0];
75
- countStr = tokens[1] || "all";
76
- }
77
- else if (tokens[0]) {
78
- countStr = tokens[0];
79
- }
80
- const types = filter ? [filter] : CACHE_TYPES;
81
- const lines = [];
82
- for (const type of types) {
83
- const store = readTypeStore(targetSid, type);
84
- const entries = Object.values(store.files);
85
- if (entries.length === 0)
86
- continue;
87
- let show = entries;
88
- if (countStr !== "all") {
89
- const n = parseInt(countStr, 10);
90
- if (!isNaN(n) && n > 0)
91
- show = entries.slice(-n);
92
- }
93
- lines.push(` ${type}:`);
94
- for (const f of show) {
95
- const src = f.msgId ? `msg_${f.msgId.slice(-8)}` : type === "input" ? "input" : "generated";
96
- lines.push(` ${f.filename} (${type}:${f.id}) [${src}]`);
97
- }
98
- }
99
- if (lines.length === 0)
100
- return `${targetSid}: ${T("no_cache")}`;
101
- return `${targetSid}:\n${lines.join("\n")}`;
102
- }
103
- return T("unknown_cmd", { cmd });
104
- },
105
- });
@@ -1,107 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin";
2
- import { DESC, T, LANG } from "../i18n.js";
3
- import { requireModelCfg } from "../config.js";
4
- import { readTypeStore, registerGeneratedFile, fetchToBuffer, buildGenFilename, extForMime } from "../cache.js";
5
- import { generateVideo } from "../providers.js";
6
- import { genError } from "../utils.js";
7
- const GEN_TIMEOUT = 300000;
8
- export const textToImageTool = tool({
9
- description: DESC.text_to_image[LANG],
10
- args: {
11
- prompt: tool.schema.string().describe(DESC.gen_args_prompt[LANG]),
12
- size: tool.schema.string().optional().describe(DESC.gen_args_size[LANG]),
13
- },
14
- execute: async ({ prompt, size }, context) => {
15
- try {
16
- const cfg = requireModelCfg("image");
17
- const body = { model: cfg.modelId, prompt };
18
- if (size)
19
- body.size = size;
20
- const resp = await fetch(`${cfg.baseURL}/images/generations`, {
21
- method: "POST",
22
- headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
23
- body: JSON.stringify(body),
24
- signal: AbortSignal.timeout(GEN_TIMEOUT),
25
- });
26
- if (!resp.ok)
27
- throw new Error(T("err_api", { status: String(resp.status), msg: (await resp.text().catch(() => "unknown")).slice(0, 200) }));
28
- const data = await resp.json();
29
- const item = data?.data?.[0];
30
- if (!item)
31
- throw new Error(T("gen_no_result"));
32
- let buffer, mime = "image/png";
33
- if (item.b64_json)
34
- buffer = Buffer.from(item.b64_json, "base64");
35
- else if (item.url) {
36
- const d = await fetchToBuffer(item.url);
37
- buffer = d.buffer;
38
- mime = d.mime;
39
- }
40
- else
41
- throw new Error(T("gen_no_result"));
42
- const store = readTypeStore(context.sessionID, "image");
43
- const r = registerGeneratedFile(context.sessionID, "image", buildGenFilename("image", mime, store.nextId), mime, context.messageID, buffer);
44
- context.metadata?.({ title: "[ImageGen]", metadata: { sessionID: context.sessionID, messageID: context.messageID } });
45
- return T("image_generated", { name: `image_${r.id}.${extForMime(mime)}`, id: `image:${r.id}`, path: r.path });
46
- }
47
- catch (e) {
48
- context.metadata?.({ title: T("meta_error") });
49
- return genError(e);
50
- }
51
- },
52
- });
53
- export const textToVideoTool = tool({
54
- description: DESC.text_to_video[LANG],
55
- args: {
56
- prompt: tool.schema.string().describe(DESC.gen_args_prompt[LANG]),
57
- duration: tool.schema.number().optional().describe(DESC.gen_args_duration[LANG]),
58
- },
59
- execute: async ({ prompt, duration }, context) => {
60
- try {
61
- const cfg = requireModelCfg("video");
62
- const url = await generateVideo(cfg, prompt, duration);
63
- const d = await fetchToBuffer(url);
64
- const store = readTypeStore(context.sessionID, "video");
65
- const r = registerGeneratedFile(context.sessionID, "video", buildGenFilename("video", d.mime, store.nextId), d.mime, context.messageID, d.buffer);
66
- context.metadata?.({ title: "[VideoGen]", metadata: { sessionID: context.sessionID, messageID: context.messageID } });
67
- return T("video_generated", { name: `video_${r.id}.${extForMime(d.mime)}`, id: `video:${r.id}`, path: r.path });
68
- }
69
- catch (e) {
70
- context.metadata?.({ title: T("meta_error") });
71
- return genError(e);
72
- }
73
- },
74
- });
75
- export const textToSpeechTool = tool({
76
- description: DESC.text_to_speech[LANG],
77
- args: {
78
- text: tool.schema.string().describe(DESC.gen_args_prompt[LANG]),
79
- voice: tool.schema.string().optional().describe(DESC.gen_args_voice[LANG]),
80
- },
81
- execute: async ({ text, voice }, context) => {
82
- try {
83
- const cfg = requireModelCfg("tts");
84
- const body = { model: cfg.modelId, input: text };
85
- if (voice)
86
- body.voice = voice;
87
- const resp = await fetch(`${cfg.baseURL}/audio/speech`, {
88
- method: "POST",
89
- headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
90
- body: JSON.stringify(body),
91
- signal: AbortSignal.timeout(GEN_TIMEOUT),
92
- });
93
- if (!resp.ok)
94
- throw new Error(T("err_api", { status: String(resp.status), msg: (await resp.text().catch(() => "unknown")).slice(0, 200) }));
95
- const buf = Buffer.from(await resp.arrayBuffer());
96
- const mime = resp.headers.get("content-type")?.split(";")[0] || "audio/mpeg";
97
- const store = readTypeStore(context.sessionID, "tts");
98
- const r = registerGeneratedFile(context.sessionID, "tts", buildGenFilename("tts", mime, store.nextId), mime, context.messageID, buf);
99
- context.metadata?.({ title: "[TTS]", metadata: { sessionID: context.sessionID, messageID: context.messageID } });
100
- return T("tts_generated", { name: `tts_${r.id}.${extForMime(mime)}`, id: `tts:${r.id}`, path: r.path });
101
- }
102
- catch (e) {
103
- context.metadata?.({ title: T("meta_error") });
104
- return genError(e);
105
- }
106
- },
107
- });