@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/vision-agent.ts +70 -193
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.213",
3
+ "version": "0.0.214",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -2,14 +2,14 @@
2
2
  * vision-agent —— 视觉代理扩展
3
3
  *
4
4
  * 让非多模态主模型(如 DeepSeek)通过 describe_image 工具完成图片识别:
5
- * 图片文件直接以 base64 data URL 放进 OpenAI 兼容 /chat/completions 请求,
6
- * 由视觉模型(models.json 中的多模态 provider)完成识别 —— 不经过任何
7
- * read 工具或中间 agent,识别过程对主模型完全透明。支持一次传入多张图片
8
- * (path 数组),请求体里同时携带全部 image_url,由模型按顺序逐张描述。
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
- * provider 的 baseUrl / apiKey ~/.pi/agent/models.json 解析,认证、代理、
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
- /** ~/.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,需要留出内存余量 */
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
- 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 };
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
- * OpenAI 兼容响应里取正文。
319
- * content 可能是字符串,也可能是分片数组(部分网关/国内端点如此),
320
- * 少数推理模型只填 reasoning_content。
321
- */
322
- function extractText(message: unknown): string {
323
- if (!message || typeof message !== "object") return "";
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
- provider: ResolvedProvider & { model: string },
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 url = normalizeBaseUrl(provider.baseUrl);
363
-
364
- let response: Response;
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
- response = await fetch(url, {
367
- method: "POST",
368
- headers: buildHeaders(provider.apiKey),
369
- body: JSON.stringify({
370
- model: provider.model,
371
- max_tokens: provider.maxTokens ?? DEFAULT_MAX_TOKENS,
372
- messages: [
373
- { role: "system", content: VISION_SYSTEM_PROMPT },
374
- {
375
- role: "user",
376
- content: [
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
- if (error instanceof Error && error.name === "TimeoutError") {
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 new Error(
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
- const apiError = data?.error?.message;
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
- const totalTokens = data?.usage?.total_tokens;
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[模型: ${provider.model}, tokens: ${tokenStr}]`;
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
- // 未配置视觉模型(或 provider 缺失)就不注册工具:agent 看不到也调不到,
456
- // 避免留下一个必然失败的僵尸工具。配置好 settings.json / models.json
457
- // 重新加载(/reload)即可生效。
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 provider = resolveProviderConfig(providerName, visionConfig.model);
525
- if (!provider) {
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: `在 ${MODELS_PATH} 中找不到 provider「${providerName}」。请检查 models.json 是否包含该 provider 的 baseUrl 配置。`,
407
+ text: `在模型注册表中找不到视觉模型「${providerName}/${visionConfig.model}」。请检查 models.json 是否配置了该 provider 与该模型。`,
532
408
  },
533
409
  ],
534
- details: { error: `provider not found: ${providerName}` },
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
- { ...provider, model: visionConfig.model },
422
+ ctx.modelRegistry,
423
+ model,
547
424
  paths,
548
425
  prompt,
549
426
  signal ?? ctx.signal,