@trim21/personal-pi-extensions 0.0.172 → 0.0.173
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/package.json +2 -1
- package/src/vision-agent.ts +537 -0
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
| [workspace-guard](#workspace-guard) | 限制文件写入在 workspace 内,外部写入需用户审批 |
|
|
11
11
|
| [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
|
|
12
12
|
| [bash-default-timeout](#bash-default-timeout) | 为 bash 工具设置默认超时(180 秒) |
|
|
13
|
+
| [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时,spawn 子 agent 识别图片 |
|
|
13
14
|
|
|
14
15
|
---
|
|
15
16
|
|
|
@@ -133,6 +134,41 @@ pi -e ./src/bash-default-timeout.ts
|
|
|
133
134
|
|
|
134
135
|
---
|
|
135
136
|
|
|
137
|
+
## vision-agent
|
|
138
|
+
|
|
139
|
+
视觉代理扩展。主模型不支持视觉(如 DeepSeek)时自动启用 `describe_image` 工具;主模型支持视觉时自动隐藏,图片由 pi 原生透传。
|
|
140
|
+
|
|
141
|
+
`describe_image` 工具只接收本地图片路径(`path`,单个或数组,一次可识别多张),图片直接以 base64 data URL 放进请求体,由视觉模型按顺序逐张描述,中间不经过任何 read 工具或 agent。内置默认 system prompt(图像识别助手),并支持 `prompt` 参数追加具体描述要求(如「图中验证码是什么」「逐字翻译图中的文字」),缺省时自动生成通用描述指令。功能与 [pi-vlm-proxy](https://github.com/lawrencewzen/pi-vlm-proxy) 一致,但配置不单独维护。
|
|
142
|
+
|
|
143
|
+
### 配置
|
|
144
|
+
|
|
145
|
+
不需要独立配置文件,直接复用 pi 已有的配置:
|
|
146
|
+
|
|
147
|
+
```jsonc
|
|
148
|
+
// ~/.pi/agent/settings.json —— 指定视觉模型
|
|
149
|
+
{
|
|
150
|
+
"defaultProvider": "axonhub",
|
|
151
|
+
"visionConfig": {
|
|
152
|
+
"provider": "axonhub", // 可选,缺省回退到 defaultProvider
|
|
153
|
+
"model": "mimo-v2.5",
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`provider` 的 `baseUrl` / `apiKey` 从 `~/.pi/agent/models.json`(pi 自定义 provider 配置)解析,认证、代理、网络全部复用 pi 自身配置。
|
|
159
|
+
|
|
160
|
+
**未配置 `visionConfig`(或 provider 缺失)时扩展不会注册 `describe_image` 工具**,agent 看不到也调不到,避免一个必然失败的僵尸工具;配置好后 `/reload` 即可生效。
|
|
161
|
+
|
|
162
|
+
### 使用
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pi -e ./src/vision-agent.ts
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**注意:** 本扩展与 pi-vlm-proxy 都注册同名 `describe_image` 工具,启用前请先从 `~/.pi/agent/settings.json` 的 `packages` 中移除 `pi-vlm-proxy`,避免工具注册冲突。
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
136
172
|
## 安装
|
|
137
173
|
|
|
138
174
|
### 通过 npm/git 包
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.173",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"pi": {
|
|
57
57
|
"extensions": [
|
|
58
|
+
"src/vision-agent.ts",
|
|
58
59
|
"src/bwrap/index.ts",
|
|
59
60
|
"src/workspace-guard.ts",
|
|
60
61
|
"src/opencode-edit.ts",
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vision-agent —— 视觉代理扩展
|
|
3
|
+
*
|
|
4
|
+
* 让非多模态主模型(如 DeepSeek)通过 describe_image 工具完成图片识别:
|
|
5
|
+
* 图片文件直接以 base64 data URL 放进 OpenAI 兼容 /chat/completions 请求,
|
|
6
|
+
* 由视觉模型(models.json 中的多模态 provider)完成识别 —— 不经过任何
|
|
7
|
+
* read 工具或中间 agent,识别过程对主模型完全透明。支持一次传入多张图片
|
|
8
|
+
* (path 数组),请求体里同时携带全部 image_url,由模型按顺序逐张描述。
|
|
9
|
+
*
|
|
10
|
+
* 配置不单独维护:视觉模型来自 ~/.pi/agent/settings.json 的 `visionConfig`
|
|
11
|
+
* (\{ provider, model \},provider 缺省时回退到 defaultProvider),
|
|
12
|
+
* provider 的 baseUrl / apiKey 从 ~/.pi/agent/models.json 解析,认证、代理、
|
|
13
|
+
* 网络全部复用 pi 自身配置。
|
|
14
|
+
*
|
|
15
|
+
* 使用前提:本扩展与 pi-vlm-proxy 都注册同名 describe_image 工具,
|
|
16
|
+
* 启用前请先从 ~/.pi/agent/settings.json 的 packages 中移除 pi-vlm-proxy,
|
|
17
|
+
* 否则工具注册会冲突。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, statSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { basename, extname, join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { Type } from "typebox";
|
|
26
|
+
|
|
27
|
+
// ── constants ────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export const TOOL_NAME = "describe_image";
|
|
30
|
+
/** ~/.pi/agent/settings.json:visionConfig(provider + model)所在文件 */
|
|
31
|
+
export const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
|
|
32
|
+
/** ~/.pi/agent/models.json:pi 自定义 provider(baseUrl/apiKey)所在文件 */
|
|
33
|
+
export const MODELS_PATH = join(homedir(), ".pi", "agent", "models.json");
|
|
34
|
+
/** 单张图片体积上限:base64 后约 1.34 倍,再整体塞进 JSON body,需要留出内存余量 */
|
|
35
|
+
export const MAX_IMAGE_BYTES = 100 * 1024 * 1024;
|
|
36
|
+
/** 单次视觉请求的默认超时。ctx.signal 在 agent 空闲时为 undefined,不能只依赖它 */
|
|
37
|
+
export const REQUEST_TIMEOUT_MS = 300_000;
|
|
38
|
+
/** 未在模型元数据中找到 maxTokens 时的输出上限 */
|
|
39
|
+
export const DEFAULT_MAX_TOKENS = 4096;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 视觉模型的默认 system prompt。agent 通过 describe_image 的 `prompt`
|
|
43
|
+
* 参数追加具体要求(如「图中验证码是什么」),未传时用通用的详细描述指令。
|
|
44
|
+
*/
|
|
45
|
+
export const VISION_SYSTEM_PROMPT = [
|
|
46
|
+
"你是一个图像识别助手。根据图片内容准确、完整地回答用户的问题。",
|
|
47
|
+
"涉及可见文字、代码、命令、数字时逐字转述,说明颜色、布局与位置关系。",
|
|
48
|
+
"默认使用中文回答。",
|
|
49
|
+
].join("\n");
|
|
50
|
+
|
|
51
|
+
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
|
|
52
|
+
const MIME_FROM_EXT: Record<string, string> = {
|
|
53
|
+
".png": "image/png",
|
|
54
|
+
".jpg": "image/jpeg",
|
|
55
|
+
".jpeg": "image/jpeg",
|
|
56
|
+
".gif": "image/gif",
|
|
57
|
+
".webp": "image/webp",
|
|
58
|
+
".bmp": "image/bmp",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** 用户主动取消(区别于真正的失败,调用方不该把它当错误汇报) */
|
|
62
|
+
export class VisionAbortError extends Error {
|
|
63
|
+
constructor(message = "已取消") {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "VisionAbortError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── types ────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export interface VisionConfigSettings {
|
|
72
|
+
provider?: string;
|
|
73
|
+
model?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ResolvedProvider {
|
|
77
|
+
baseUrl: string;
|
|
78
|
+
apiKey?: string;
|
|
79
|
+
/** 从模型元数据里解析的输出上限 */
|
|
80
|
+
maxTokens?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** OpenAI 兼容响应里我们用到的字段(其余忽略) */
|
|
84
|
+
interface ChatCompletionResponse {
|
|
85
|
+
choices?: { message?: unknown }[];
|
|
86
|
+
usage?: { total_tokens?: unknown };
|
|
87
|
+
error?: { message?: unknown };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── 纯函数(可测试)──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/** 把 path(单个或数组)规整成非空字符串列表,顺序不变 */
|
|
93
|
+
export function resolveImagePaths(params: { path?: string | string[] }): string[] {
|
|
94
|
+
return toTrimmedList(params.path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toTrimmedList(value: string | string[] | undefined): string[] {
|
|
98
|
+
if (value === undefined) return [];
|
|
99
|
+
const list = Array.isArray(value) ? value : [value];
|
|
100
|
+
return list.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 读取 ~/.pi/agent/settings.json 的 visionConfig。
|
|
105
|
+
* provider 缺省时回退到 defaultProvider;文件缺失 / JSON 损坏 / 无 visionConfig
|
|
106
|
+
* 时返回 undefined,由调用方决定如何提示。
|
|
107
|
+
*/
|
|
108
|
+
export function loadVisionConfig(settingsPath = SETTINGS_PATH): VisionConfigSettings | undefined {
|
|
109
|
+
let raw: string;
|
|
110
|
+
try {
|
|
111
|
+
raw = readFileSync(settingsPath, "utf8");
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const parsed: unknown = JSON.parse(raw);
|
|
117
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
118
|
+
const settings = parsed as Record<string, unknown>;
|
|
119
|
+
const vc = settings.visionConfig;
|
|
120
|
+
if (!vc || typeof vc !== "object" || Array.isArray(vc)) return undefined;
|
|
121
|
+
const config = vc as Record<string, unknown>;
|
|
122
|
+
const provider =
|
|
123
|
+
typeof config.provider === "string" ? config.provider.trim() || undefined : undefined;
|
|
124
|
+
const defaultProvider =
|
|
125
|
+
typeof settings.defaultProvider === "string"
|
|
126
|
+
? settings.defaultProvider.trim() || undefined
|
|
127
|
+
: undefined;
|
|
128
|
+
return {
|
|
129
|
+
provider: provider ?? defaultProvider,
|
|
130
|
+
model: typeof config.model === "string" ? config.model.trim() || undefined : undefined,
|
|
131
|
+
};
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 从 ~/.pi/agent/models.json 解析 provider 的 baseUrl / apiKey,
|
|
139
|
+
* 并从模型元数据里找该模型的 maxTokens。provider 不存在或缺少 baseUrl
|
|
140
|
+
* 时返回 undefined。
|
|
141
|
+
*/
|
|
142
|
+
export function resolveProviderConfig(
|
|
143
|
+
providerName: string,
|
|
144
|
+
model: string,
|
|
145
|
+
modelsPath = MODELS_PATH,
|
|
146
|
+
): ResolvedProvider | undefined {
|
|
147
|
+
let raw: string;
|
|
148
|
+
try {
|
|
149
|
+
raw = readFileSync(modelsPath, "utf8");
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
let parsed: unknown;
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(raw);
|
|
156
|
+
} catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
160
|
+
const providers = (parsed as Record<string, unknown>).providers;
|
|
161
|
+
const entry =
|
|
162
|
+
providers && typeof providers === "object" && !Array.isArray(providers)
|
|
163
|
+
? (providers as Record<string, unknown>)[providerName]
|
|
164
|
+
: undefined;
|
|
165
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
|
|
166
|
+
const provider = entry as Record<string, unknown>;
|
|
167
|
+
const baseUrl = typeof provider.baseUrl === "string" ? provider.baseUrl.trim() : "";
|
|
168
|
+
if (!baseUrl) return undefined;
|
|
169
|
+
return {
|
|
170
|
+
baseUrl,
|
|
171
|
+
apiKey:
|
|
172
|
+
typeof provider.apiKey === "string" && provider.apiKey.trim()
|
|
173
|
+
? provider.apiKey.trim()
|
|
174
|
+
: undefined,
|
|
175
|
+
maxTokens: findModelMaxTokens(provider.models, model),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function findModelMaxTokens(models: unknown, modelId: string): number | undefined {
|
|
180
|
+
if (!Array.isArray(models)) return undefined;
|
|
181
|
+
for (const entry of models) {
|
|
182
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
183
|
+
const m = entry as Record<string, unknown>;
|
|
184
|
+
if (m.id !== modelId) continue;
|
|
185
|
+
return typeof m.maxTokens === "number" && m.maxTokens > 0 ? m.maxTokens : undefined;
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 解析 apiKey,支持 $ENV_NAME 引用环境变量(与 pi 配置语法一致) */
|
|
191
|
+
export function resolveApiKey(apiKey: string | undefined): string | undefined {
|
|
192
|
+
if (!apiKey) return undefined;
|
|
193
|
+
const m = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(apiKey.trim());
|
|
194
|
+
if (m) return process.env[m[1]] || undefined;
|
|
195
|
+
return apiKey;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 规范化 baseUrl:自动补 /chat/completions */
|
|
199
|
+
export function normalizeBaseUrl(baseUrl: string): string {
|
|
200
|
+
let url = baseUrl.trim().replace(/\/+$/, "");
|
|
201
|
+
if (!url.endsWith("/chat/completions")) {
|
|
202
|
+
url += "/chat/completions";
|
|
203
|
+
}
|
|
204
|
+
return url;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 拼装发给视觉模型的用户指令:agent 传了具体 prompt 就用它,
|
|
209
|
+
* 否则退化为通用的详细描述指令。多图时追加「按顺序逐张描述」的要求。
|
|
210
|
+
*/
|
|
211
|
+
export function buildPrompt(customPrompt?: string, imageCount = 1): string {
|
|
212
|
+
const instruction =
|
|
213
|
+
customPrompt?.trim() ||
|
|
214
|
+
"详细描述这张图片的全部内容,包括所有可见文字、代码、命令、菜单、按钮、数字、图表,以及颜色、布局和位置关系。直接用中文描述。";
|
|
215
|
+
if (imageCount > 1) {
|
|
216
|
+
return `共 ${imageCount} 张图片,请按顺序逐张描述,说明每张图片分别的内容。${instruction}`;
|
|
217
|
+
}
|
|
218
|
+
return instruction;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 只依赖 input 字段判断模型是否多模态,不绑定 pi 内部类型 */
|
|
222
|
+
export function isMultimodal(model: { input?: readonly string[] } | undefined): boolean {
|
|
223
|
+
return !!model && Array.isArray(model.input) && model.input.includes("image");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── 图片加载与 API 调用 ─────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/** 按文件头识别真实图片类型,识别不出返回 undefined */
|
|
229
|
+
function sniffMime(buf: Buffer): string | undefined {
|
|
230
|
+
if (buf.length >= 8 && buf.readUInt32BE(0) === 0x89504e47) return "image/png";
|
|
231
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
|
|
232
|
+
if (buf.length >= 6 && buf.toString("ascii", 0, 4) === "GIF8") return "image/gif";
|
|
233
|
+
if (
|
|
234
|
+
buf.length >= 12 &&
|
|
235
|
+
buf.toString("ascii", 0, 4) === "RIFF" &&
|
|
236
|
+
buf.toString("ascii", 8, 12) === "WEBP"
|
|
237
|
+
) {
|
|
238
|
+
return "image/webp";
|
|
239
|
+
}
|
|
240
|
+
if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d) return "image/bmp";
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 读取图片文件 → base64 + mimeType + 展示名 */
|
|
245
|
+
function loadImageBytes(path: string): { base64: string; mimeType: string; label: string } {
|
|
246
|
+
const ext = extname(path).toLowerCase();
|
|
247
|
+
if (!IMAGE_EXTS.has(ext)) throw new Error(`不支持的文件格式: ${ext || "(无扩展名)"}`);
|
|
248
|
+
|
|
249
|
+
let stat;
|
|
250
|
+
try {
|
|
251
|
+
stat = statSync(path);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`无法读取文件 ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
255
|
+
{ cause: error },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
if (!stat.isFile()) throw new Error(`不是普通文件: ${path}`);
|
|
259
|
+
if (stat.size === 0) throw new Error(`文件为空: ${path}`);
|
|
260
|
+
if (stat.size > MAX_IMAGE_BYTES) {
|
|
261
|
+
throw new Error(`图片过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),上限 10MB`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const buffer = readFileSync(path);
|
|
265
|
+
// 扩展名可能骗人(.png 里装的是 JPEG),以文件头为准
|
|
266
|
+
const mimeType = sniffMime(buffer) || MIME_FROM_EXT[ext] || "image/png";
|
|
267
|
+
return { base64: buffer.toString("base64"), mimeType, label: basename(path) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildHeaders(apiKey: string | undefined): Record<string, string> {
|
|
271
|
+
const resolved = resolveApiKey(apiKey);
|
|
272
|
+
return {
|
|
273
|
+
"Content-Type": "application/json",
|
|
274
|
+
...(resolved && { Authorization: `Bearer ${resolved}` }),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 合并调用方 signal 与本地超时;调用方未传时仍然有超时兜底 */
|
|
279
|
+
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
280
|
+
const timeout = AbortSignal.timeout(ms);
|
|
281
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 从 OpenAI 兼容响应里取正文。
|
|
286
|
+
* content 可能是字符串,也可能是分片数组(部分网关/国内端点如此),
|
|
287
|
+
* 少数推理模型只填 reasoning_content。
|
|
288
|
+
*/
|
|
289
|
+
function extractText(message: unknown): string {
|
|
290
|
+
if (!message || typeof message !== "object") return "";
|
|
291
|
+
const msg = message as Record<string, unknown>;
|
|
292
|
+
const content = msg.content;
|
|
293
|
+
if (typeof content === "string" && content.trim()) return content;
|
|
294
|
+
if (Array.isArray(content)) {
|
|
295
|
+
const joined = content
|
|
296
|
+
.map((part: unknown) => {
|
|
297
|
+
if (typeof part === "string") return part;
|
|
298
|
+
if (
|
|
299
|
+
part &&
|
|
300
|
+
typeof part === "object" &&
|
|
301
|
+
typeof (part as Record<string, unknown>).text === "string"
|
|
302
|
+
) {
|
|
303
|
+
return (part as Record<string, unknown>).text as string;
|
|
304
|
+
}
|
|
305
|
+
return "";
|
|
306
|
+
})
|
|
307
|
+
.filter(Boolean)
|
|
308
|
+
.join("");
|
|
309
|
+
if (joined.trim()) return joined;
|
|
310
|
+
}
|
|
311
|
+
if (typeof msg.reasoning_content === "string" && msg.reasoning_content.trim()) {
|
|
312
|
+
return msg.reasoning_content;
|
|
313
|
+
}
|
|
314
|
+
return "";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 调用 OpenAI 兼容的多模态端点识别图片。所有图片文件直接以 base64 data URL
|
|
319
|
+
* 放进同一个 user 消息 —— 不需要模型或 agent 先读取图片文件。
|
|
320
|
+
* @returns 视觉模型返回的文字描述
|
|
321
|
+
*/
|
|
322
|
+
export async function callVision(
|
|
323
|
+
provider: ResolvedProvider & { model: string },
|
|
324
|
+
paths: string[],
|
|
325
|
+
prompt: string,
|
|
326
|
+
signal?: AbortSignal,
|
|
327
|
+
): Promise<string> {
|
|
328
|
+
const loaded = paths.map((p) => loadImageBytes(p));
|
|
329
|
+
const url = normalizeBaseUrl(provider.baseUrl);
|
|
330
|
+
|
|
331
|
+
let response: Response;
|
|
332
|
+
try {
|
|
333
|
+
response = await fetch(url, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: buildHeaders(provider.apiKey),
|
|
336
|
+
body: JSON.stringify({
|
|
337
|
+
model: provider.model,
|
|
338
|
+
max_tokens: provider.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
339
|
+
messages: [
|
|
340
|
+
{ role: "system", content: VISION_SYSTEM_PROMPT },
|
|
341
|
+
{
|
|
342
|
+
role: "user",
|
|
343
|
+
content: [
|
|
344
|
+
...loaded.map(({ base64, mimeType }) => ({
|
|
345
|
+
type: "image_url",
|
|
346
|
+
image_url: { url: `data:${mimeType};base64,${base64}` },
|
|
347
|
+
})),
|
|
348
|
+
{ type: "text", text: prompt },
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
}),
|
|
353
|
+
signal: withTimeout(signal, REQUEST_TIMEOUT_MS),
|
|
354
|
+
});
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
357
|
+
throw new Error(`请求超时(${REQUEST_TIMEOUT_MS / 1000}s): ${url}`, { cause: error });
|
|
358
|
+
}
|
|
359
|
+
if (error instanceof Error && error.name === "AbortError") throw new VisionAbortError();
|
|
360
|
+
throw new Error(
|
|
361
|
+
`请求失败 (${url}): ${error instanceof Error ? error.message : String(error)}`,
|
|
362
|
+
{ cause: error },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!response.ok) {
|
|
367
|
+
const errText = await response.text().catch(() => "");
|
|
368
|
+
throw new Error(`API ${response.status}: ${errText.slice(0, 300)}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const data = (await response.json().catch(() => null)) as ChatCompletionResponse | null;
|
|
372
|
+
const text = extractText(data?.choices?.[0]?.message);
|
|
373
|
+
if (!text) {
|
|
374
|
+
const apiError = data?.error?.message;
|
|
375
|
+
const hint = typeof apiError === "string" && apiError ? `: ${apiError.slice(0, 200)}` : "";
|
|
376
|
+
throw new Error(`API 未返回内容${hint}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const totalTokens = data?.usage?.total_tokens;
|
|
380
|
+
const tokenStr = typeof totalTokens === "number" ? String(totalTokens) : "?";
|
|
381
|
+
const labels = loaded.map((l) => l.label).join(", ");
|
|
382
|
+
return `[${labels}]\n${text}\n[模型: ${provider.model}, tokens: ${tokenStr}]`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── extension ────────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
export default function visionAgent(pi: ExtensionAPI) {
|
|
388
|
+
// 只依赖 input 字段,不绑定 pi 内部类型
|
|
389
|
+
interface AnyModel {
|
|
390
|
+
id?: string;
|
|
391
|
+
input?: readonly string[];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 根据当前主模型能力同步工具可见性:
|
|
396
|
+
* - 多模态 → 隐藏 describe_image,图片由 pi 原生透传,零额外 API 调用
|
|
397
|
+
* - text-only → 启用 describe_image 代理
|
|
398
|
+
*/
|
|
399
|
+
function syncVisionMode(model: AnyModel | undefined, notify?: (msg: string) => void) {
|
|
400
|
+
const active = pi.getActiveTools();
|
|
401
|
+
const hasTool = active.includes(TOOL_NAME);
|
|
402
|
+
if (hasTool && isMultimodal(model)) {
|
|
403
|
+
pi.setActiveTools(active.filter((t) => t !== TOOL_NAME));
|
|
404
|
+
if (notify) notify(`主模型 ${model?.id ?? "?"} 支持视觉 → 已隐藏 ${TOOL_NAME},图片原生透传`);
|
|
405
|
+
} else if (!hasTool && !isMultimodal(model)) {
|
|
406
|
+
pi.setActiveTools([...active, TOOL_NAME]);
|
|
407
|
+
if (notify)
|
|
408
|
+
notify(`主模型 ${model?.id ?? "?"} 不支持视觉 → 已启用 ${TOOL_NAME},由视觉模型代理识别`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 模型切换时自动同步(/model、Ctrl+P、会话恢复都会触发)
|
|
413
|
+
pi.on("model_select", (event, ctx) => {
|
|
414
|
+
syncVisionMode(event.model, (msg) => ctx.ui.notify(msg, "info"));
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// 启动 / 会话恢复时初始化
|
|
418
|
+
pi.on("session_start", (_event, ctx) => {
|
|
419
|
+
syncVisionMode(ctx.model);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// 未配置视觉模型(或 provider 缺失)就不注册工具:agent 看不到也调不到,
|
|
423
|
+
// 避免留下一个必然失败的僵尸工具。配置好 settings.json / models.json 后
|
|
424
|
+
// 重新加载(/reload)即可生效。
|
|
425
|
+
const visionConfig = loadVisionConfig();
|
|
426
|
+
if (!visionConfig?.model) return;
|
|
427
|
+
const providerName = visionConfig.provider ?? "default";
|
|
428
|
+
if (!resolveProviderConfig(providerName, visionConfig.model)) return;
|
|
429
|
+
|
|
430
|
+
pi.registerTool({
|
|
431
|
+
name: TOOL_NAME,
|
|
432
|
+
label: "Describe Image",
|
|
433
|
+
description:
|
|
434
|
+
"调用视觉模型识别图片文件内容,返回详细文字描述(适用于当前主模型不支持视觉的情况;主模型支持视觉时会自动隐藏本工具)。" +
|
|
435
|
+
"参数:① path=本地图片路径(单个或数组,数组一次识别多张);" +
|
|
436
|
+
"② prompt=可选的具体描述要求(如「图中验证码是什么」「逐字翻译图中的文字」),缺省时自动生成通用描述指令。" +
|
|
437
|
+
"聊天里粘贴的截图若当前模型不支持视觉,先用 write 工具存成文件再传 path。",
|
|
438
|
+
promptSnippet: "调用视觉模型识别图片文件(可一次多张)",
|
|
439
|
+
parameters: Type.Object({
|
|
440
|
+
path: Type.Optional(
|
|
441
|
+
Type.Union([
|
|
442
|
+
Type.String({
|
|
443
|
+
description: "本地图片路径,如 /path/to/screenshot.png。",
|
|
444
|
+
}),
|
|
445
|
+
Type.Array(
|
|
446
|
+
Type.String({
|
|
447
|
+
description: "多个本地图片路径,一次识别多张。",
|
|
448
|
+
}),
|
|
449
|
+
),
|
|
450
|
+
]),
|
|
451
|
+
),
|
|
452
|
+
prompt: Type.Optional(
|
|
453
|
+
Type.String({
|
|
454
|
+
description:
|
|
455
|
+
"可选:具体的描述要求,如「图中验证码是什么」「逐字翻译图中的文字」。缺省时自动生成通用描述指令。",
|
|
456
|
+
}),
|
|
457
|
+
),
|
|
458
|
+
}),
|
|
459
|
+
|
|
460
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
461
|
+
try {
|
|
462
|
+
const paths = resolveImagePaths({ path: params.path });
|
|
463
|
+
if (paths.length === 0) {
|
|
464
|
+
return {
|
|
465
|
+
isError: true,
|
|
466
|
+
content: [
|
|
467
|
+
{
|
|
468
|
+
type: "text",
|
|
469
|
+
text: "需要提供 path(本地图片路径,单个或数组,数组一次识别多张)。",
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
details: { error: "no image path provided" },
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const visionConfig = loadVisionConfig();
|
|
477
|
+
if (!visionConfig?.model) {
|
|
478
|
+
return {
|
|
479
|
+
isError: true,
|
|
480
|
+
content: [
|
|
481
|
+
{
|
|
482
|
+
type: "text",
|
|
483
|
+
text: `未配置视觉模型。请在 ${SETTINGS_PATH} 添加 visionConfig,例如: { "provider": "axonhub", "model": "mimo-v2.5" }`,
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
details: { error: "visionConfig not configured" },
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const providerName = visionConfig.provider ?? "default";
|
|
491
|
+
const provider = resolveProviderConfig(providerName, visionConfig.model);
|
|
492
|
+
if (!provider) {
|
|
493
|
+
return {
|
|
494
|
+
isError: true,
|
|
495
|
+
content: [
|
|
496
|
+
{
|
|
497
|
+
type: "text",
|
|
498
|
+
text: `在 ${MODELS_PATH} 中找不到 provider「${providerName}」。请检查 models.json 是否包含该 provider 的 baseUrl 配置。`,
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
details: { error: `provider not found: ${providerName}` },
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const description = await callVision(
|
|
506
|
+
{ ...provider, model: visionConfig.model },
|
|
507
|
+
paths,
|
|
508
|
+
buildPrompt(params.prompt, paths.length),
|
|
509
|
+
signal ?? ctx.signal,
|
|
510
|
+
);
|
|
511
|
+
return {
|
|
512
|
+
content: [{ type: "text", text: description }],
|
|
513
|
+
details: {
|
|
514
|
+
provider: providerName,
|
|
515
|
+
model: visionConfig.model,
|
|
516
|
+
paths,
|
|
517
|
+
count: paths.length,
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
} catch (error) {
|
|
521
|
+
// 用户主动取消不是失败,不该以 isError 污染对话历史
|
|
522
|
+
if (error instanceof VisionAbortError) {
|
|
523
|
+
return {
|
|
524
|
+
content: [{ type: "text", text: "图片识别已取消。" }],
|
|
525
|
+
details: { cancelled: true },
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
529
|
+
return {
|
|
530
|
+
isError: true,
|
|
531
|
+
content: [{ type: "text", text: `识别失败: ${message}` }],
|
|
532
|
+
details: { error: message },
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
}
|