@xiaoqiong0v0/opencode-file-tool 1.0.5 → 1.1.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,222 +1,16 @@
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", "file-tool");
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(); }
1
+ import { T } from "./i18n.js";
2
+ import { log, ENABLED, loadCfg } from "./config.js";
3
+ 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";
73
7
  try {
74
8
  loadCfg();
75
9
  }
76
10
  catch (e) {
77
11
  log.error("初始化失败", e instanceof Error ? e : Error(String(e)));
78
12
  }
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 () => {
13
+ export const fileToolPlugin = async () => {
220
14
  log.loaded();
221
15
  return {
222
16
  config: async (config) => {
@@ -231,6 +25,7 @@ export const FileTool = async () => {
231
25
  knownSessions.add(sid);
232
26
  if (props?.parentID)
233
27
  sessionParents.set(sid, props.parentID);
28
+ migrateLegacyCache(sid);
234
29
  }
235
30
  if (event.type === "session.updated" && sid) {
236
31
  if (!knownSessions.has(sid))
@@ -249,158 +44,39 @@ export const FileTool = async () => {
249
44
  const part = props?.part;
250
45
  if (part?.type === "file" && (part?.mime || "").startsWith("image/")) {
251
46
  const fn = (part.filename || part.name || "");
252
- if (fn) {
253
- const data = readSession(sid || "");
254
- if (!sid)
255
- return;
256
- const fid = data.nextId++;
47
+ if (fn && sid) {
257
48
  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);
49
+ if (ENABLED) {
50
+ const ext = extForMime(part.mime || "image/png");
51
+ const r = registerInputFile(sid, `input_${Date.now() % 100000}.${ext}`, part.mime || "image/png", msgId, (part.url || ""));
52
+ log.info(`${sid}: cached ${fn} as input:${r.id}`);
264
53
  }
265
54
  else {
266
- msgs.push({ msgId, fileIds: [fid] });
55
+ log.info(`${sid}: skip cache ${fn} (disabled)`);
267
56
  }
268
- writeSession(sid, data);
269
57
  }
270
58
  }
271
59
  }
60
+ if (event.type === "message.removed" && sid) {
61
+ const msgId = props?.messageID || "";
62
+ log.info(`${sid}: message.removed msgId=${msgId.slice(-8) || "(empty)"}`);
63
+ if (msgId)
64
+ removeMsgCache(sid, msgId);
65
+ }
66
+ if (event.type === "message.part.removed" && sid) {
67
+ const msgId = props?.messageID || props?.info?.id || "";
68
+ log.info(`${sid}: message.part.removed msgId=${msgId.slice(-8) || "(empty)"}`);
69
+ if (msgId)
70
+ removeMsgCache(sid, msgId);
71
+ }
272
72
  },
273
73
  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
- }),
74
+ analyze_image: analyzeImageTool,
75
+ text_to_image: textToImageTool,
76
+ text_to_video: textToVideoTool,
77
+ text_to_speech: textToSpeechTool,
78
+ file_tool: fileTool,
403
79
  },
404
80
  };
405
81
  };
406
- export { FileTool as default };
82
+ export default fileToolPlugin;
package/dist/i18n.js ADDED
@@ -0,0 +1,67 @@
1
+ export let LANG = "en";
2
+ export function setLang(lang) { LANG = lang === "zh" ? "zh" : "en"; }
3
+ export const TX = {
4
+ file_not_found: { zh: "文件不存在: {path}", en: "File not found: {path}" },
5
+ file_id_not_found: { zh: "文件ID不存在: {id}", en: "File ID not found: {id}" },
6
+ file_data_not_found: { zh: "文件数据不存在: {id}", en: "File data not found: {id}" },
7
+ not_an_image: { zh: "不是图片文件: {name} ({mime})", en: "Not an image: {name} ({mime})" },
8
+ unsupported_source: { zh: "不支持的图片来源: {source}", en: "Unsupported source: {source}" },
9
+ describe_image: { zh: "请详细描述这张图片({name})的内容", en: "Describe this image ({name})" },
10
+ current_model: { zh: "当前模型:\n{model}\n可用模型:\n{list}", en: "Current models:\n{model}\nAvailable models:\n{list}" },
11
+ model_not_set: { zh: "未设置", en: "not set" },
12
+ model_switched: { zh: "{type} 模型已切换为: {model}", en: "{type} model set to: {model}" },
13
+ model_types: { zh: "vision: 图像分析, image: 文生图, video: 文生视频, tts: 文生语音", en: "vision: analyze image, image: text-to-image, video: text-to-video, tts: text-to-speech" },
14
+ model_usage: { zh: "用法: set-provider <模型名>(vision,默认)或 set-provider <类型>:<模型名>,如 set-provider image:xxx/yyy", en: "Usage: set-provider <model> (vision, default) or set-provider <type>:<model>, e.g. set-provider image:xxx/yyy" },
15
+ specify_model: { zh: "请指定模型名", en: "Specify a model name" },
16
+ model_not_configured: { zh: "{type} 模型未配置,请在 file-tool.jsonc 的 models 中设置(可用 set-provider 切换)", en: "{type} model not configured, set it in models of file-tool.jsonc (use set-provider)" },
17
+ image_generated: { zh: "图片已生成并缓存: {name} (file_id:{id})\n路径: {path}\n用 analyze_image file_id:{id} 查看", en: "Image generated & cached: {name} (file_id:{id})\nPath: {path}\nuse analyze_image file_id:{id}" },
18
+ video_generated: { zh: "视频已生成并缓存: {name} (file_id:{id})\n路径: {path}", en: "Video generated & cached: {name} (file_id:{id})\nPath: {path}" },
19
+ video_pending: { zh: "视频生成中(任务 {id}),已提交,稍后可查", en: "Video generating (task {id}), submitted, check later" },
20
+ video_failed: { zh: "视频生成失败: {msg}", en: "Video generation failed: {msg}" },
21
+ tts_generated: { zh: "语音已生成并缓存: {name} (file_id:{id})\n路径: {path}", en: "Audio generated & cached: {name} (file_id:{id})\nPath: {path}" },
22
+ gen_failed: { zh: "生成失败: {msg}", en: "Generation failed: {msg}" },
23
+ gen_no_result: { zh: "接口未返回生成结果", en: "No generation result from API" },
24
+ gen_download_failed: { zh: "下载生成结果失败: {url}", en: "Failed to download result: {url}" },
25
+ unknown_cmd: { zh: "未知命令: {cmd}\n可用: list-provider, set-provider [<类型>:]<模型名>, list-cache [类型] [数量], enable, disable, enable-save, disable-save, status", en: "Unknown command: {cmd}\nAvailable: list-provider, set-provider [<type>:]<model>, list-cache [type] [count], enable, disable, enable-save, disable-save, status" },
26
+ config_error: { zh: "请在 file-tool.jsonc 中配置 models.{type}(provider/modelId)或 apiKey+apiBaseUrl+model", en: "Set models.{type} (provider/modelId) or apiKey+apiBaseUrl+model in file-tool.jsonc" },
27
+ meta_failed: { zh: "分析失败", en: "Failed" },
28
+ meta_skip: { zh: "跳过", en: "Skip" },
29
+ meta_not_found: { zh: "文件不存在", en: "Not found" },
30
+ meta_image: { zh: "图片", en: "Image" },
31
+ meta_error: { zh: "分析出错", en: "Error" },
32
+ no_cache: { zh: "[] (无缓存)", en: "[] (no cache)" },
33
+ enabled: { zh: "已启用", en: "Enabled" },
34
+ disabled: { zh: "已禁用", en: "Disabled" },
35
+ status: { zh: "图片缓存: {s}\n视觉模型: {m}\n文生图: {i}\n文生视频: {v}\n文生语音: {t}", en: "Image cache: {s}\nVision: {m}\nImage gen: {i}\nVideo gen: {v}\nTTS: {t}" },
36
+ status_cmd: { zh: "查看缓存开关状态", en: "Show cache status" },
37
+ vision_prompt_default: { zh: "请详细描述这张图片的内容,返回格式: [文件名] 描述", en: "Describe this image in detail, format: [filename] description" },
38
+ err_resolve_config: { zh: "无法解析模型配置: {model}。请在 file-tool.jsonc 中配置 models.{type} (provider/modelId) 或 apiKey+apiBaseUrl+model", en: "Cannot resolve model config: {model}. Set models.{type} (provider/modelId) or apiKey+apiBaseUrl+model in file-tool.jsonc" },
39
+ err_api: { zh: "API {status}: {msg}", en: "API {status}: {msg}" },
40
+ empty_response: { zh: "(空)", en: "(empty)" },
41
+ uncached: { zh: "未缓存", en: "uncached" },
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
+ };
46
+ 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)" },
60
+ };
61
+ export function T(key, params) {
62
+ const entry = TX[key] || { zh: key, en: key };
63
+ const t = LANG === "zh" ? entry.zh : entry.en;
64
+ if (!params)
65
+ return t;
66
+ return Object.entries(params).reduce((s, [k, v]) => s.replace(`{${k}}`, v), t);
67
+ }
@@ -0,0 +1,61 @@
1
+ import { T } from "./i18n.js";
2
+ import { isAgnesProvider, postJson, getJson } from "./utils.js";
3
+ // 视频生成 - agnes 私有接口
4
+ export async function generateVideoAgnes(cfg, prompt, duration) {
5
+ const dur = duration || 5;
6
+ const fps = 24;
7
+ const frames = dur * fps + 1;
8
+ const data = await postJson(`${cfg.baseURL}/videos`, cfg.apiKey, {
9
+ model: cfg.modelId, prompt, num_frames: Math.min(frames, 441), frame_rate: fps,
10
+ });
11
+ const taskId = data?.id || data?.task_id || data?.video_id;
12
+ if (!taskId)
13
+ throw new Error(T("gen_no_result"));
14
+ const deadline = Date.now() + 600000;
15
+ while (Date.now() < deadline) {
16
+ await new Promise(r => setTimeout(r, 10000));
17
+ const pd = await getJson(`https://apihub.agnes-ai.com/agnesapi?video_id=${taskId}&model_name=${cfg.modelId}`, cfg.apiKey);
18
+ const st = pd?.status || pd?.state || "";
19
+ if (st === "completed") {
20
+ const url = pd?.metadata?.url || pd?.url || "";
21
+ if (url)
22
+ return url;
23
+ }
24
+ else if (st === "failed" || st === "error") {
25
+ throw new Error(T("video_failed", { msg: pd?.error || st }));
26
+ }
27
+ }
28
+ throw new Error(T("video_failed", { msg: "timeout" }));
29
+ }
30
+ // 视频生成 - OpenAI 标准接口
31
+ export async function generateVideoOpenAI(cfg, prompt, duration) {
32
+ const body = { model: cfg.modelId, prompt };
33
+ if (duration)
34
+ body.duration = duration;
35
+ const data = await postJson(`${cfg.baseURL}/videos/generations`, cfg.apiKey, body);
36
+ const taskId = data?.id || data?.task_id || data?.data?.[0]?.id;
37
+ if (!taskId)
38
+ throw new Error(T("gen_no_result"));
39
+ const deadline = Date.now() + 600000;
40
+ while (Date.now() < deadline) {
41
+ await new Promise(r => setTimeout(r, 10000));
42
+ const pd = await getJson(`${cfg.baseURL}/videos/generations/${taskId}`, cfg.apiKey);
43
+ const st = pd?.status || pd?.state || "";
44
+ if (st === "completed" || st === "succeeded") {
45
+ const out = pd?.output?.[0] || pd?.output;
46
+ const url = typeof out === "string" ? out : out?.url || "";
47
+ if (url)
48
+ return url;
49
+ }
50
+ else if (st === "failed" || st === "error") {
51
+ throw new Error(T("video_failed", { msg: pd?.error || st }));
52
+ }
53
+ }
54
+ throw new Error(T("video_failed", { msg: "timeout" }));
55
+ }
56
+ // 视频生成 - 路由:按域名选择适配器
57
+ export async function generateVideo(cfg, prompt, duration) {
58
+ if (isAgnesProvider(cfg.baseURL))
59
+ return generateVideoAgnes(cfg, prompt, duration);
60
+ return generateVideoOpenAI(cfg, prompt, duration);
61
+ }
@@ -0,0 +1,94 @@
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
+ });