@trim21/personal-pi-extensions 0.0.213 → 0.0.214
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/package.json +1 -1
- package/src/vision-agent.ts +70 -193
package/package.json
CHANGED
package/src/vision-agent.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* vision-agent —— 视觉代理扩展
|
|
3
3
|
*
|
|
4
4
|
* 让非多模态主模型(如 DeepSeek)通过 describe_image 工具完成图片识别:
|
|
5
|
-
* 图片文件直接以 base64
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* 图片文件直接以 base64 交给 pi 的 AI SDK(ctx.modelRegistry.complete),
|
|
6
|
+
* 由视觉模型完成识别 —— 不经过任何 read 工具或中间 agent,识别过程对主
|
|
7
|
+
* 模型完全透明。支持一次传入多张图片(path 数组),user 消息里同时携带
|
|
8
|
+
* 全部图片,由模型按顺序逐张描述。
|
|
9
9
|
*
|
|
10
10
|
* 配置不单独维护:视觉模型来自 ~/.pi/agent/settings.json 的 `visionConfig`
|
|
11
|
-
* (\{ provider, model \},provider 缺省时回退到 defaultProvider),
|
|
12
|
-
*
|
|
11
|
+
* (\{ provider, model \},provider 缺省时回退到 defaultProvider),provider
|
|
12
|
+
* 的 baseUrl / apiKey 由 pi 的模型注册表解析(models.json),认证、代理、
|
|
13
13
|
* 网络全部复用 pi 自身配置。
|
|
14
14
|
*
|
|
15
15
|
* 使用前提:本扩展与 pi-vlm-proxy 都注册同名 describe_image 工具,
|
|
@@ -21,6 +21,16 @@ import { readFileSync, statSync } from "node:fs";
|
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { basename, extname, join } from "node:path";
|
|
23
23
|
|
|
24
|
+
import {
|
|
25
|
+
type Api,
|
|
26
|
+
type ApiStreamOptions,
|
|
27
|
+
type AssistantMessage,
|
|
28
|
+
contentText,
|
|
29
|
+
type Context,
|
|
30
|
+
type ImageContent,
|
|
31
|
+
type Model,
|
|
32
|
+
type TextContent,
|
|
33
|
+
} from "@earendil-works/pi-ai";
|
|
24
34
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
25
35
|
import { Type } from "typebox";
|
|
26
36
|
|
|
@@ -29,9 +39,7 @@ import { Type } from "typebox";
|
|
|
29
39
|
export const TOOL_NAME = "describe_image";
|
|
30
40
|
/** ~/.pi/agent/settings.json:visionConfig(provider + model)所在文件 */
|
|
31
41
|
export const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
|
|
32
|
-
/**
|
|
33
|
-
export const MODELS_PATH = join(homedir(), ".pi", "agent", "models.json");
|
|
34
|
-
/** 单张图片体积上限:base64 后约 1.34 倍,再整体塞进 JSON body,需要留出内存余量 */
|
|
42
|
+
/** 单张图片体积上限:base64 后约 1.34 倍,再整体塞进请求 body,需要留出内存余量 */
|
|
35
43
|
export const MAX_IMAGE_BYTES = 100 * 1024 * 1024;
|
|
36
44
|
/** 单次视觉请求的默认超时。ctx.signal 在 agent 空闲时为 undefined,不能只依赖它 */
|
|
37
45
|
export const REQUEST_TIMEOUT_MS = 300_000;
|
|
@@ -73,18 +81,17 @@ export interface VisionConfigSettings {
|
|
|
73
81
|
model?: string;
|
|
74
82
|
}
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
error?: { message?: unknown };
|
|
84
|
+
/**
|
|
85
|
+
* 视觉识别所需的模型注册表操作:扩展传 ctx.modelRegistry,测试传 mock。
|
|
86
|
+
* 结构化类型(duck typing),只声明用到的两个方法。
|
|
87
|
+
*/
|
|
88
|
+
export interface ModelRegistryLike {
|
|
89
|
+
find(provider: string, modelId: string): Model<Api> | undefined;
|
|
90
|
+
complete(
|
|
91
|
+
model: Model<Api>,
|
|
92
|
+
context: Context,
|
|
93
|
+
options?: ApiStreamOptions<Api> & { signal?: AbortSignal },
|
|
94
|
+
): Promise<AssistantMessage>;
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
// ── 纯函数(可测试)──────────────────────────────────────────────────────────
|
|
@@ -134,76 +141,6 @@ export function loadVisionConfig(settingsPath = SETTINGS_PATH): VisionConfigSett
|
|
|
134
141
|
}
|
|
135
142
|
}
|
|
136
143
|
|
|
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
144
|
/**
|
|
208
145
|
* 拼装发给视觉模型的用户指令:agent 传了具体 prompt 就用它,
|
|
209
146
|
* 否则退化为通用的详细描述指令。多图时追加「按顺序逐张描述」的要求。
|
|
@@ -300,14 +237,6 @@ function loadImageBytes(path: string): { base64: string; mimeType: string; label
|
|
|
300
237
|
return { base64: buffer.toString("base64"), mimeType, label: basename(path) };
|
|
301
238
|
}
|
|
302
239
|
|
|
303
|
-
function buildHeaders(apiKey: string | undefined): Record<string, string> {
|
|
304
|
-
const resolved = resolveApiKey(apiKey);
|
|
305
|
-
return {
|
|
306
|
-
"Content-Type": "application/json",
|
|
307
|
-
...(resolved && { Authorization: `Bearer ${resolved}` }),
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
240
|
/** 合并调用方 signal 与本地超时;调用方未传时仍然有超时兜底 */
|
|
312
241
|
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
313
242
|
const timeout = AbortSignal.timeout(ms);
|
|
@@ -315,104 +244,53 @@ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
|
315
244
|
}
|
|
316
245
|
|
|
317
246
|
/**
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const msg = message as Record<string, unknown>;
|
|
325
|
-
const content = msg.content;
|
|
326
|
-
if (typeof content === "string" && content.trim()) return content;
|
|
327
|
-
if (Array.isArray(content)) {
|
|
328
|
-
const joined = content
|
|
329
|
-
.map((part: unknown) => {
|
|
330
|
-
if (typeof part === "string") return part;
|
|
331
|
-
if (
|
|
332
|
-
part &&
|
|
333
|
-
typeof part === "object" &&
|
|
334
|
-
typeof (part as Record<string, unknown>).text === "string"
|
|
335
|
-
) {
|
|
336
|
-
return (part as Record<string, unknown>).text as string;
|
|
337
|
-
}
|
|
338
|
-
return "";
|
|
339
|
-
})
|
|
340
|
-
.filter(Boolean)
|
|
341
|
-
.join("");
|
|
342
|
-
if (joined.trim()) return joined;
|
|
343
|
-
}
|
|
344
|
-
if (typeof msg.reasoning_content === "string" && msg.reasoning_content.trim()) {
|
|
345
|
-
return msg.reasoning_content;
|
|
346
|
-
}
|
|
347
|
-
return "";
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* 调用 OpenAI 兼容的多模态端点识别图片。所有图片文件直接以 base64 data URL
|
|
352
|
-
* 放进同一个 user 消息 —— 不需要模型或 agent 先读取图片文件。
|
|
353
|
-
* @returns 视觉模型返回的文字描述
|
|
247
|
+
* 通过模型注册表调用视觉模型识别图片。所有图片文件直接以 base64 放进
|
|
248
|
+
* 同一个 user 消息 —— 不需要模型或 agent 先读取图片文件。走 pi 的 AI SDK
|
|
249
|
+
* (modelRegistry.complete),复用 provider 解析与 thinking/重试/usage 等
|
|
250
|
+
* 基础设施,不手写 HTTP 请求。
|
|
251
|
+
*
|
|
252
|
+
* @returns 视觉模型返回的文字描述(含图片标签与 token 元信息)
|
|
354
253
|
*/
|
|
355
254
|
export async function callVision(
|
|
356
|
-
|
|
255
|
+
registry: ModelRegistryLike,
|
|
256
|
+
model: Model<Api>,
|
|
357
257
|
paths: string[],
|
|
358
258
|
prompt: string,
|
|
359
259
|
signal?: AbortSignal,
|
|
360
260
|
): Promise<string> {
|
|
361
261
|
const loaded = paths.map((p) => loadImageBytes(p));
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
262
|
+
const content: (ImageContent | TextContent)[] = [
|
|
263
|
+
...loaded.map(
|
|
264
|
+
({ base64, mimeType }) => ({ type: "image", data: base64, mimeType }) satisfies ImageContent,
|
|
265
|
+
),
|
|
266
|
+
{ type: "text", text: prompt } satisfies TextContent,
|
|
267
|
+
];
|
|
268
|
+
let result: AssistantMessage;
|
|
365
269
|
try {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
...loaded.map(({ base64, mimeType }) => ({
|
|
378
|
-
type: "image_url",
|
|
379
|
-
image_url: { url: `data:${mimeType};base64,${base64}` },
|
|
380
|
-
})),
|
|
381
|
-
{ type: "text", text: prompt },
|
|
382
|
-
],
|
|
383
|
-
},
|
|
384
|
-
],
|
|
385
|
-
}),
|
|
386
|
-
signal: withTimeout(signal, REQUEST_TIMEOUT_MS),
|
|
387
|
-
});
|
|
270
|
+
result = await registry.complete(
|
|
271
|
+
model,
|
|
272
|
+
{
|
|
273
|
+
systemPrompt: VISION_SYSTEM_PROMPT,
|
|
274
|
+
messages: [{ role: "user", content, timestamp: Date.now() }],
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
278
|
+
signal: withTimeout(signal, REQUEST_TIMEOUT_MS),
|
|
279
|
+
},
|
|
280
|
+
);
|
|
388
281
|
} catch (error) {
|
|
389
|
-
|
|
390
|
-
throw new Error(`请求超时(${REQUEST_TIMEOUT_MS / 1000}s): ${url}`, { cause: error });
|
|
391
|
-
}
|
|
282
|
+
// 用户主动取消(signal abort)不是失败,转成 VisionAbortError 由调用方处理
|
|
392
283
|
if (error instanceof Error && error.name === "AbortError") throw new VisionAbortError();
|
|
393
|
-
throw
|
|
394
|
-
`请求失败 (${url}): ${error instanceof Error ? error.message : String(error)}`,
|
|
395
|
-
{ cause: error },
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
if (!response.ok) {
|
|
400
|
-
const errText = await response.text().catch(() => "");
|
|
401
|
-
throw new Error(`API ${response.status}: ${errText.slice(0, 300)}`);
|
|
284
|
+
throw error;
|
|
402
285
|
}
|
|
403
|
-
|
|
404
|
-
const data = (await response.json().catch(() => null)) as ChatCompletionResponse | null;
|
|
405
|
-
const text = extractText(data?.choices?.[0]?.message);
|
|
286
|
+
const text = contentText(result.content).trim();
|
|
406
287
|
if (!text) {
|
|
407
|
-
|
|
408
|
-
const hint = typeof apiError === "string" && apiError ? `: ${apiError.slice(0, 200)}` : "";
|
|
409
|
-
throw new Error(`API 未返回内容${hint}`);
|
|
288
|
+
throw new Error("API 未返回内容");
|
|
410
289
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const tokenStr = typeof totalTokens === "number" ? String(totalTokens) : "?";
|
|
290
|
+
const tokenStr =
|
|
291
|
+
typeof result.usage?.totalTokens === "number" ? String(result.usage.totalTokens) : "?";
|
|
414
292
|
const labels = loaded.map((l) => l.label).join(", ");
|
|
415
|
-
return `[${labels}]\n${text}\n[模型: ${
|
|
293
|
+
return `[${labels}]\n${text}\n[模型: ${model.id}, tokens: ${tokenStr}]`;
|
|
416
294
|
}
|
|
417
295
|
|
|
418
296
|
// ── extension ────────────────────────────────────────────────────────────────
|
|
@@ -452,13 +330,11 @@ export default function visionAgent(pi: ExtensionAPI) {
|
|
|
452
330
|
syncVisionMode(ctx.model);
|
|
453
331
|
});
|
|
454
332
|
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
333
|
+
// 未配置视觉模型就不注册工具:agent 看不到也调不到。provider 的
|
|
334
|
+
// baseUrl/apiKey 由 pi 的模型注册表在调用时解析(execute 里 find),
|
|
335
|
+
// 配置好 settings.json / models.json 后重新加载(/reload)即可生效。
|
|
458
336
|
const visionConfig = loadVisionConfig();
|
|
459
337
|
if (!visionConfig?.model) return;
|
|
460
|
-
const providerName = visionConfig.provider ?? "default";
|
|
461
|
-
if (!resolveProviderConfig(providerName, visionConfig.model)) return;
|
|
462
338
|
|
|
463
339
|
pi.registerTool({
|
|
464
340
|
name: TOOL_NAME,
|
|
@@ -521,17 +397,17 @@ export default function visionAgent(pi: ExtensionAPI) {
|
|
|
521
397
|
}
|
|
522
398
|
|
|
523
399
|
const providerName = visionConfig.provider ?? "default";
|
|
524
|
-
const
|
|
525
|
-
if (!
|
|
400
|
+
const model = ctx.modelRegistry.find(providerName, visionConfig.model);
|
|
401
|
+
if (!model) {
|
|
526
402
|
return {
|
|
527
403
|
isError: true,
|
|
528
404
|
content: [
|
|
529
405
|
{
|
|
530
406
|
type: "text",
|
|
531
|
-
text:
|
|
407
|
+
text: `在模型注册表中找不到视觉模型「${providerName}/${visionConfig.model}」。请检查 models.json 是否配置了该 provider 与该模型。`,
|
|
532
408
|
},
|
|
533
409
|
],
|
|
534
|
-
details: { error: `
|
|
410
|
+
details: { error: `model not found: ${providerName}/${visionConfig.model}` },
|
|
535
411
|
};
|
|
536
412
|
}
|
|
537
413
|
|
|
@@ -543,7 +419,8 @@ export default function visionAgent(pi: ExtensionAPI) {
|
|
|
543
419
|
});
|
|
544
420
|
|
|
545
421
|
const description = await callVision(
|
|
546
|
-
|
|
422
|
+
ctx.modelRegistry,
|
|
423
|
+
model,
|
|
547
424
|
paths,
|
|
548
425
|
prompt,
|
|
549
426
|
signal ?? ctx.signal,
|