@buyi1net/pi-toolkit 0.0.1
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/LICENSE +21 -0
- package/README.md +45 -0
- package/assembler.ts +127 -0
- package/config.ts +201 -0
- package/i18n.ts +693 -0
- package/index.ts +99 -0
- package/menu/items.ts +264 -0
- package/menu/panels.ts +91 -0
- package/menu/settings-list.ts +84 -0
- package/menu/theme.ts +31 -0
- package/menu/toolkit-menu.ts +139 -0
- package/module.ts +156 -0
- package/modules/eyes/chain.ts +173 -0
- package/modules/eyes/config.ts +319 -0
- package/modules/eyes/index.ts +161 -0
- package/modules/eyes/menu.ts +828 -0
- package/modules/eyes/pi-model-backend.ts +245 -0
- package/modules/eyes/resilience.ts +161 -0
- package/modules/eyes/vision-bridge.ts +232 -0
- package/modules/eyes/vision-cache.ts +86 -0
- package/modules/eyes/vision-json.ts +19 -0
- package/modules/eyes/vision-preprocess.ts +192 -0
- package/modules/eyes/vision-probe.ts +21 -0
- package/modules/eyes/vision-prompt.ts +90 -0
- package/modules/eyes/vision-tool.ts +59 -0
- package/modules/eyes/vision-types.ts +33 -0
- package/modules/index.ts +10 -0
- package/modules/subagents/agents/researcher.md +51 -0
- package/modules/subagents/agents/scout.md +40 -0
- package/modules/subagents/agents/worker.md +79 -0
- package/modules/subagents/config.json.example +6 -0
- package/modules/subagents/config.ts +144 -0
- package/modules/subagents/index.ts +91 -0
- package/modules/subagents/menu.ts +388 -0
- package/modules/subagents/src/activity.ts +511 -0
- package/modules/subagents/src/agents.ts +126 -0
- package/modules/subagents/src/command.ts +37 -0
- package/modules/subagents/src/dependencies.ts +246 -0
- package/modules/subagents/src/diagnostics.ts +13 -0
- package/modules/subagents/src/display.ts +94 -0
- package/modules/subagents/src/headless.ts +342 -0
- package/modules/subagents/src/herdr.ts +203 -0
- package/modules/subagents/src/index.ts +2798 -0
- package/modules/subagents/src/inspect-tool.ts +839 -0
- package/modules/subagents/src/launch-config.ts +196 -0
- package/modules/subagents/src/layout-budget.ts +26 -0
- package/modules/subagents/src/list-tool.ts +292 -0
- package/modules/subagents/src/message-tool.ts +808 -0
- package/modules/subagents/src/names.ts +17 -0
- package/modules/subagents/src/pane-layout.ts +49 -0
- package/modules/subagents/src/params.ts +145 -0
- package/modules/subagents/src/renderers.ts +181 -0
- package/modules/subagents/src/result.ts +43 -0
- package/modules/subagents/src/retention.ts +114 -0
- package/modules/subagents/src/route-error.ts +212 -0
- package/modules/subagents/src/routing.ts +235 -0
- package/modules/subagents/src/runtime-registry.ts +159 -0
- package/modules/subagents/src/session.ts +801 -0
- package/modules/subagents/src/status.ts +513 -0
- package/modules/subagents/src/stop-tool.ts +235 -0
- package/modules/subagents/src/subagent-done.ts +590 -0
- package/modules/subagents/src/subagent-tool.ts +1155 -0
- package/modules/subagents/src/surface.ts +355 -0
- package/modules/subagents/src/team-dispatch-tool.ts +219 -0
- package/modules/subagents/src/team.ts +232 -0
- package/modules/subagents/src/tmux.ts +210 -0
- package/modules/subagents/src/tools/safe-bash.ts +72 -0
- package/modules/subagents/src/types.ts +131 -0
- package/modules/tui/adapter/provider-usage.ts +143 -0
- package/modules/tui/config.ts +50 -0
- package/modules/tui/index.ts +120 -0
- package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
- package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
- package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
- package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
- package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
- package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
- package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
- package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
- package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
- package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
- package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
- package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
- package/modules/tui/kernel/usage-core.ts +2 -0
- package/modules/tui/kernel/usage-node.ts +2 -0
- package/modules/tui/menu.ts +418 -0
- package/modules/tui/plugin/editor.ts +396 -0
- package/modules/tui/plugin/footer.ts +161 -0
- package/modules/tui/plugin/index.ts +30 -0
- package/modules/tui/plugin/lifecycle.ts +637 -0
- package/modules/tui/plugin/package-order.ts +169 -0
- package/modules/tui/plugin/screen-transition.ts +203 -0
- package/modules/tui/plugin/settings-config.ts +297 -0
- package/modules/tui/plugin/status-sources.ts +49 -0
- package/modules/tui/plugin/transition-gate.ts +261 -0
- package/modules/tui/renderer/custom-header.ts +157 -0
- package/modules/tui/renderer/editor.ts +70 -0
- package/modules/tui/renderer/header.ts +72 -0
- package/modules/tui/renderer/icons.ts +149 -0
- package/modules/tui/renderer/pi-installer-logo.ts +194 -0
- package/modules/tui/status/auto-compaction.ts +67 -0
- package/modules/tui/status/project-status.ts +655 -0
- package/modules/tui/status/provider-status.ts +120 -0
- package/modules/tui/status/runtime-status.ts +307 -0
- package/modules/tui/status/session-status.ts +325 -0
- package/modules/tui/status/status-config.ts +95 -0
- package/modules/tui/status/status-segments.ts +263 -0
- package/modules/tui/status/turn-telemetry.ts +466 -0
- package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
- package/modules/tui/themes/UPSTREAM.md +7 -0
- package/modules/tui/themes/catppuccin-latte.json +80 -0
- package/modules/tui/themes/catppuccin-mocha.json +79 -0
- package/modules/tui/themes/crimson-noir.json +85 -0
- package/modules/tui/themes/dracula.json +79 -0
- package/modules/tui/themes/everforest-dark.json +85 -0
- package/modules/tui/themes/gruvbox-dark.json +85 -0
- package/modules/tui/themes/gruvbox-light.json +85 -0
- package/modules/tui/themes/matrix.json +85 -0
- package/modules/tui/themes/nord.json +85 -0
- package/modules/tui/themes/one-dark.json +85 -0
- package/modules/tui/themes/rose-pine-dawn.json +85 -0
- package/modules/tui/themes/rose-pine.json +85 -0
- package/modules/tui/themes/solarized-dark.json +85 -0
- package/modules/tui/themes/solarized-light.json +85 -0
- package/modules/tui/themes/tokyo-night-storm.json +79 -0
- package/modules/tui/themes/tokyo-night.json +79 -0
- package/package.json +28 -0
- package/services.ts +38 -0
- package/toolkit.ts +147 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Pi 视觉模型发现、通用调用与显式能力测试。
|
|
2
|
+
// 所有调用都经 Pi 原生 modelRegistry.complete(),本模块不读取凭证、不自行构造 HTTP 请求。
|
|
3
|
+
// 模型对象类型从 ModelRegistry 的方法签名推导,避免直接 import pi-ai(本包只声明 pi 宿主 peer)。
|
|
4
|
+
|
|
5
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
/** pi 的模型对象(Model<Api>),由注册表签名推导 */
|
|
8
|
+
export type PiVisionModel = NonNullable<ReturnType<ModelRegistry["find"]>>;
|
|
9
|
+
|
|
10
|
+
export interface PiVisionModelSelection {
|
|
11
|
+
provider: string;
|
|
12
|
+
modelId: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PiVisionImage {
|
|
16
|
+
data: string;
|
|
17
|
+
mediaType: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PiVisionModelCandidate extends PiVisionModelSelection {
|
|
21
|
+
name: string;
|
|
22
|
+
api: string;
|
|
23
|
+
available: boolean;
|
|
24
|
+
auth: {
|
|
25
|
+
configured: boolean;
|
|
26
|
+
source?: string;
|
|
27
|
+
label?: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PiVisionCallOptions {
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
maxTokens?: number;
|
|
34
|
+
systemPrompt?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AutomaticPiVisionModelOptions {
|
|
38
|
+
currentModel?: PiVisionModelSelection;
|
|
39
|
+
allowedModels?: readonly PiVisionModelSelection[] | null;
|
|
40
|
+
excludedModels?: ReadonlySet<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PiVisionProbeResult {
|
|
44
|
+
passed: boolean;
|
|
45
|
+
matched: number;
|
|
46
|
+
total: number;
|
|
47
|
+
answers: string[];
|
|
48
|
+
text: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type PiVisionModelRegistry = Pick<
|
|
52
|
+
ModelRegistry,
|
|
53
|
+
"getAll" | "getAvailable" | "find" | "getProviderAuthStatus" | "complete"
|
|
54
|
+
>;
|
|
55
|
+
|
|
56
|
+
export function piVisionModelKey(provider: string, modelId: string): string {
|
|
57
|
+
return `${provider}\0${modelId}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 列出全部声明支持图片的模型,并分别保留认证与当前可用状态。 */
|
|
61
|
+
export function discoverPiVisionModels(registry: PiVisionModelRegistry): PiVisionModelCandidate[] {
|
|
62
|
+
const available = new Set(registry.getAvailable().map((model) => piVisionModelKey(model.provider, model.id)));
|
|
63
|
+
return registry.getAll()
|
|
64
|
+
.filter((model) => model.input.includes("image"))
|
|
65
|
+
.map((model) => {
|
|
66
|
+
const auth = registry.getProviderAuthStatus(model.provider);
|
|
67
|
+
return {
|
|
68
|
+
provider: model.provider,
|
|
69
|
+
modelId: model.id,
|
|
70
|
+
name: model.name,
|
|
71
|
+
api: model.api,
|
|
72
|
+
available: available.has(piVisionModelKey(model.provider, model.id)),
|
|
73
|
+
auth: {
|
|
74
|
+
configured: auth.configured,
|
|
75
|
+
...(auth.source ? { source: auth.source } : {}),
|
|
76
|
+
...(auth.label ? { label: auth.label } : {}),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function compareText(left: string, right: string): number {
|
|
83
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 从 Pi 当前可用的图片模型中按稳定规则选择一个,不发起模型调用。 */
|
|
87
|
+
export function selectAutomaticPiVisionModel(
|
|
88
|
+
registry: PiVisionModelRegistry,
|
|
89
|
+
options: AutomaticPiVisionModelOptions = {},
|
|
90
|
+
): PiVisionModelSelection | undefined {
|
|
91
|
+
const allowed = options.allowedModels === null || options.allowedModels === undefined
|
|
92
|
+
? undefined
|
|
93
|
+
: new Set(options.allowedModels.map((model) => piVisionModelKey(model.provider, model.modelId)));
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
const candidates = registry.getAvailable()
|
|
96
|
+
.filter((model) => model.input.includes("image"))
|
|
97
|
+
.filter((model) => {
|
|
98
|
+
const key = piVisionModelKey(model.provider, model.id);
|
|
99
|
+
if (seen.has(key) || options.excludedModels?.has(key) || (allowed && !allowed.has(key))) return false;
|
|
100
|
+
seen.add(key);
|
|
101
|
+
return true;
|
|
102
|
+
})
|
|
103
|
+
.map((model) => ({ provider: model.provider, modelId: model.id }));
|
|
104
|
+
|
|
105
|
+
const currentKey = options.currentModel
|
|
106
|
+
? piVisionModelKey(options.currentModel.provider, options.currentModel.modelId)
|
|
107
|
+
: undefined;
|
|
108
|
+
candidates.sort((left, right) => {
|
|
109
|
+
const leftKey = piVisionModelKey(left.provider, left.modelId);
|
|
110
|
+
const rightKey = piVisionModelKey(right.provider, right.modelId);
|
|
111
|
+
if (currentKey) {
|
|
112
|
+
if (leftKey === currentKey && rightKey !== currentKey) return -1;
|
|
113
|
+
if (rightKey === currentKey && leftKey !== currentKey) return 1;
|
|
114
|
+
const leftSameProvider = left.provider === options.currentModel!.provider;
|
|
115
|
+
const rightSameProvider = right.provider === options.currentModel!.provider;
|
|
116
|
+
if (leftSameProvider !== rightSameProvider) return leftSameProvider ? -1 : 1;
|
|
117
|
+
}
|
|
118
|
+
return compareText(`${left.provider}/${left.modelId}`, `${right.provider}/${right.modelId}`);
|
|
119
|
+
});
|
|
120
|
+
return candidates[0];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 只按 provider 与 modelId 联合键查找,不用显示名或模糊匹配。 */
|
|
124
|
+
export function resolvePiVisionModel(
|
|
125
|
+
registry: PiVisionModelRegistry,
|
|
126
|
+
selection: PiVisionModelSelection,
|
|
127
|
+
): PiVisionModel | undefined {
|
|
128
|
+
const model = registry.find(selection.provider, selection.modelId);
|
|
129
|
+
return model?.input.includes("image") ? model : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function abortError(message: string): Error {
|
|
133
|
+
const error = new Error(message);
|
|
134
|
+
error.name = "AbortError";
|
|
135
|
+
return error;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 经 Pi 原生 provider 调度发送图片,不读取凭证,也不自行构造 HTTP 请求。 */
|
|
139
|
+
export async function callPiVisionModel(
|
|
140
|
+
registry: PiVisionModelRegistry,
|
|
141
|
+
selection: PiVisionModelSelection,
|
|
142
|
+
prompt: string,
|
|
143
|
+
images: PiVisionImage[],
|
|
144
|
+
options: PiVisionCallOptions = {},
|
|
145
|
+
): Promise<string> {
|
|
146
|
+
options.signal?.throwIfAborted();
|
|
147
|
+
const model = resolvePiVisionModel(registry, selection);
|
|
148
|
+
if (!model) {
|
|
149
|
+
throw new Error(`Pi 视觉模型不可用:${selection.provider}/${selection.modelId}`);
|
|
150
|
+
}
|
|
151
|
+
if (images.length === 0) throw new Error("调用 Pi 视觉模型时至少需要一张图片");
|
|
152
|
+
const maxTokens = Math.min(options.maxTokens ?? model.maxTokens, model.maxTokens);
|
|
153
|
+
|
|
154
|
+
const result = await registry.complete(
|
|
155
|
+
model,
|
|
156
|
+
{
|
|
157
|
+
...(options.systemPrompt ? { systemPrompt: options.systemPrompt } : {}),
|
|
158
|
+
messages: [{
|
|
159
|
+
role: "user",
|
|
160
|
+
timestamp: Date.now(),
|
|
161
|
+
content: [
|
|
162
|
+
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mediaType })),
|
|
163
|
+
{ type: "text" as const, text: prompt },
|
|
164
|
+
],
|
|
165
|
+
}],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
169
|
+
maxTokens,
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (result.stopReason === "aborted") {
|
|
174
|
+
throw abortError(result.errorMessage || "Pi 视觉模型请求已取消");
|
|
175
|
+
}
|
|
176
|
+
if (result.stopReason === "error") {
|
|
177
|
+
throw new Error(result.errorMessage || `Pi 视觉模型请求失败:${selection.provider}/${selection.modelId}`);
|
|
178
|
+
}
|
|
179
|
+
if (result.errorMessage) throw new Error(result.errorMessage);
|
|
180
|
+
|
|
181
|
+
const text = result.content
|
|
182
|
+
.filter((block): block is Extract<typeof block, { type: "text" }> => block.type === "text")
|
|
183
|
+
.map((block) => block.text)
|
|
184
|
+
.join("\n")
|
|
185
|
+
.trim();
|
|
186
|
+
if (!text) throw new Error(`Pi 视觉模型未返回文本:${selection.provider}/${selection.modelId}`);
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeProbeValue(value: string): string {
|
|
191
|
+
return value.trim().toLowerCase();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseProbeAnswers(text: string): string[] {
|
|
195
|
+
const trimmed = text.trim();
|
|
196
|
+
const start = trimmed.indexOf("[");
|
|
197
|
+
const end = trimmed.lastIndexOf("]");
|
|
198
|
+
if (start < 0 || end <= start) return [];
|
|
199
|
+
try {
|
|
200
|
+
const parsed = JSON.parse(trimmed.slice(start, end + 1));
|
|
201
|
+
return Array.isArray(parsed) && parsed.every((value) => typeof value === "string")
|
|
202
|
+
? parsed.map(normalizeProbeValue)
|
|
203
|
+
: [];
|
|
204
|
+
} catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function evaluatePiVisionProbe(text: string, expected: readonly string[]): PiVisionProbeResult {
|
|
210
|
+
const normalizedExpected = expected.map(normalizeProbeValue);
|
|
211
|
+
const answers = parseProbeAnswers(text);
|
|
212
|
+
const matched = normalizedExpected.reduce(
|
|
213
|
+
(count, value, index) => count + (answers[index] === value ? 1 : 0),
|
|
214
|
+
0,
|
|
215
|
+
);
|
|
216
|
+
const total = normalizedExpected.length;
|
|
217
|
+
const threshold = total === 0 ? 1 : Math.ceil(total * 5 / 6);
|
|
218
|
+
return { passed: total > 0 && matched >= threshold, matched, total, answers, text };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function buildPiVisionProbePrompt(expected: readonly string[]): string {
|
|
222
|
+
if (expected.length === 0) throw new Error("Pi 视觉模型测试至少需要一个预期值");
|
|
223
|
+
// 排序后再放进提示,避免候选标签的排列意外泄露测试图答案。
|
|
224
|
+
const labels = [...new Set(expected.map(normalizeProbeValue))].sort();
|
|
225
|
+
return (
|
|
226
|
+
`The image contains ${expected.length} colored cells. Read them from left to right, then top to bottom. ` +
|
|
227
|
+
`Use only these labels: ${labels.join(", ")}. Return only one JSON array with ${expected.length} strings.`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 仅在调用方明确触发时,使用调用方提供的测试图验证模型是否真正能看图。 */
|
|
232
|
+
export async function testPiVisionModel(
|
|
233
|
+
registry: PiVisionModelRegistry,
|
|
234
|
+
selection: PiVisionModelSelection,
|
|
235
|
+
image: PiVisionImage,
|
|
236
|
+
expected: readonly string[],
|
|
237
|
+
options: PiVisionCallOptions = {},
|
|
238
|
+
): Promise<PiVisionProbeResult> {
|
|
239
|
+
const prompt = buildPiVisionProbePrompt(expected);
|
|
240
|
+
const text = await callPiVisionModel(registry, selection, prompt, [image], {
|
|
241
|
+
...options,
|
|
242
|
+
maxTokens: options.maxTokens ?? 128,
|
|
243
|
+
});
|
|
244
|
+
return evaluatePiVisionProbe(text, expected);
|
|
245
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// 视觉后端失败分类与熔断。
|
|
2
|
+
// 思路移植自 dsh-vision-router/lib/vision-resilience.js(MIT),按 pi 扩展环境精简:
|
|
3
|
+
// 保留"分类 → 决定换后端还是熔断 → 本轮全失败短路"三层,去掉凭证指纹与多会话 scope。
|
|
4
|
+
|
|
5
|
+
export type FailureKind =
|
|
6
|
+
| "AUTH"
|
|
7
|
+
| "RATE_LIMIT"
|
|
8
|
+
| "TIMEOUT"
|
|
9
|
+
| "SERVER"
|
|
10
|
+
| "INVALID_REQUEST"
|
|
11
|
+
| "NETWORK"
|
|
12
|
+
| "QUOTA"
|
|
13
|
+
| "OTHER";
|
|
14
|
+
|
|
15
|
+
export interface BackendFailure {
|
|
16
|
+
kind: FailureKind;
|
|
17
|
+
retryAfterMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 分类正则沿用 dsh 调教过的集合与顺序:先确定性失败,后环境性失败
|
|
21
|
+
const AUTH_PATTERNS = [/\b401\b/, /\b403\b/, /unauthorized/i, /invalid api[ -]?key/i, /forbidden/i, /authentication/i];
|
|
22
|
+
const RATE_LIMIT_PATTERNS = [/\b429\b/, /rate.?limit/i, /too many requests/i];
|
|
23
|
+
const TIMEOUT_PATTERNS = [/abort/i, /timeout/i, /etimedout/i, /timed ?out/i, /deadline exceeded/i];
|
|
24
|
+
const SERVER_PATTERNS = [/\b500\b/, /\b502\b/, /\b503\b/, /\b504\b/, /bad gateway/i, /service unavailable/i];
|
|
25
|
+
const INVALID_REQUEST_PATTERNS = [/\b400\b/, /\b404\b/, /\b413\b/, /\b422\b/, /invalid request/i, /does not support image/i, /invalid model/i, /no such model/i, /model not exist/i, /request body/i];
|
|
26
|
+
const NETWORK_PATTERNS = [/econn/i, /enotfound/i, /network/i, /fetch failed/i, /socket/i, /connection reset/i, /dns/i];
|
|
27
|
+
const QUOTA_PATTERNS = [/\b402\b/, /insufficient/i, /balance/i, /credits/i];
|
|
28
|
+
|
|
29
|
+
const KIND_BY_PATTERN: Array<[FailureKind, RegExp[]]> = [
|
|
30
|
+
["AUTH", AUTH_PATTERNS],
|
|
31
|
+
["RATE_LIMIT", RATE_LIMIT_PATTERNS],
|
|
32
|
+
["TIMEOUT", TIMEOUT_PATTERNS],
|
|
33
|
+
["SERVER", SERVER_PATTERNS],
|
|
34
|
+
["INVALID_REQUEST", INVALID_REQUEST_PATTERNS],
|
|
35
|
+
["NETWORK", NETWORK_PATTERNS],
|
|
36
|
+
["QUOTA", QUOTA_PATTERNS],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** HTTP 状态码优先于报错文本分类。 */
|
|
40
|
+
export function kindForStatus(status: number | undefined): FailureKind | undefined {
|
|
41
|
+
if (status === 401 || status === 403) return "AUTH";
|
|
42
|
+
if (status === 429) return "RATE_LIMIT";
|
|
43
|
+
if (status === 402) return "QUOTA";
|
|
44
|
+
if (status === 400 || status === 404 || status === 413 || status === 422) return "INVALID_REQUEST";
|
|
45
|
+
if (status !== undefined && status >= 500 && status <= 599) return "SERVER";
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function classifyFailure(error: { status?: number; message: string; retryAfterMs?: number }): BackendFailure {
|
|
50
|
+
let kind = kindForStatus(error.status);
|
|
51
|
+
if (kind === undefined) {
|
|
52
|
+
kind =
|
|
53
|
+
KIND_BY_PATTERN.find(([, patterns]) => patterns.some((p) => p.test(error.message)))?.[0] ?? "OTHER";
|
|
54
|
+
}
|
|
55
|
+
return { kind, retryAfterMs: error.retryAfterMs };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** per-backend 熔断器:认证类熔断 10 分钟,限频按 Retry-After 冷却。 */
|
|
59
|
+
const AUTH_TRIP_MS = 10 * 60 * 1000;
|
|
60
|
+
const DEFAULT_RATE_COOLDOWN_MS = 60 * 1000;
|
|
61
|
+
|
|
62
|
+
export class VisionCircuit {
|
|
63
|
+
private backends = new Map<string, { authUntil?: number; cooldownUntil?: number }>();
|
|
64
|
+
|
|
65
|
+
inspect(id: string, _turn: number, now = Date.now()): { blocked: boolean; reason?: string } {
|
|
66
|
+
const hit = this.backends.get(id);
|
|
67
|
+
if (!hit) return { blocked: false };
|
|
68
|
+
if (hit.cooldownUntil !== undefined && hit.cooldownUntil > now) {
|
|
69
|
+
return { blocked: true, reason: "rate-limit cooldown" };
|
|
70
|
+
}
|
|
71
|
+
if (hit.authUntil !== undefined && hit.authUntil > now) {
|
|
72
|
+
return { blocked: true, reason: "auth failure cooldown" };
|
|
73
|
+
}
|
|
74
|
+
return { blocked: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
record(id: string, failure: BackendFailure, _turn: number, now = Date.now()): void {
|
|
78
|
+
const hit = this.backends.get(id) ?? {};
|
|
79
|
+
this.backends.set(id, hit);
|
|
80
|
+
if (failure.kind === "AUTH" || failure.kind === "QUOTA") {
|
|
81
|
+
hit.authUntil = now + AUTH_TRIP_MS;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (failure.kind === "RATE_LIMIT") {
|
|
85
|
+
const cooldown = failure.retryAfterMs ?? DEFAULT_RATE_COOLDOWN_MS;
|
|
86
|
+
hit.cooldownUntil = Math.max(hit.cooldownUntil ?? 0, now + cooldown);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// INVALID_REQUEST / TIMEOUT / SERVER / NETWORK / OTHER 不熔断:
|
|
90
|
+
// 调用方可能修正图片、参数或请求体后再次使用同一后端。
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
clear(id: string): void {
|
|
94
|
+
this.backends.delete(id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 本轮失败记忆:全部后端都失败后,后续调用不再碰网络。 */
|
|
99
|
+
export class TurnMemory {
|
|
100
|
+
private turn = 0;
|
|
101
|
+
private failedAll = false;
|
|
102
|
+
private attempted: string[] = [];
|
|
103
|
+
|
|
104
|
+
newTurn(turn: number): void {
|
|
105
|
+
this.turn = turn;
|
|
106
|
+
this.failedAll = false;
|
|
107
|
+
this.attempted = [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get currentTurn(): number {
|
|
111
|
+
return this.turn;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get allFailed(): boolean {
|
|
115
|
+
return this.failedAll;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
recordAttempt(entry: string): void {
|
|
119
|
+
this.attempted.push(entry);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get attempts(): string[] {
|
|
123
|
+
return [...this.attempted];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
markAllFailed(): void {
|
|
127
|
+
this.failedAll = true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const DO_NOT_RETRY_ADVICE =
|
|
132
|
+
"Vision backends are unavailable for this turn (auth failure, rate limit, timeout or outage). " +
|
|
133
|
+
"Do NOT call backend-dependent vision tools again this turn with only a reworded question — rephrasing cannot fix an " +
|
|
134
|
+
"auth, rate-limit or infrastructure failure. Answer from the information you already have and " +
|
|
135
|
+
"continue the text task; local-only tools such as crop, colors, pixel diff, foreground extraction, trace, " +
|
|
136
|
+
"HTML screenshot and Tesseract OCR may still work. Tell the user the remote vision backend is temporarily unavailable.";
|
|
137
|
+
|
|
138
|
+
const CORRECT_REQUEST_ADVICE =
|
|
139
|
+
"The backend rejected this request as invalid or too large. Correct the image path, format, count, size, " +
|
|
140
|
+
"or tool parameters before retrying; do not retry the identical request.";
|
|
141
|
+
|
|
142
|
+
const CODE_FOR_ONLY_KIND: Partial<Record<FailureKind, string>> = {
|
|
143
|
+
AUTH: "VISION_AUTH_FAILED",
|
|
144
|
+
RATE_LIMIT: "VISION_RATE_LIMITED",
|
|
145
|
+
TIMEOUT: "VISION_TIMEOUT",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** 组结构化失败 JSON:工具描述里的 FAILURE SEMANTICS 承诺的就是这个形状。 */
|
|
149
|
+
export function buildFailureJson(kinds: FailureKind[], attempted: string[]): string {
|
|
150
|
+
const set = new Set(kinds);
|
|
151
|
+
const correctable = set.has("INVALID_REQUEST");
|
|
152
|
+
const code =
|
|
153
|
+
set.size === 1 ? CODE_FOR_ONLY_KIND[[...set][0]] ?? "VISION_BACKEND_UNAVAILABLE" : "VISION_BACKEND_UNAVAILABLE";
|
|
154
|
+
return JSON.stringify({
|
|
155
|
+
ok: false,
|
|
156
|
+
code,
|
|
157
|
+
retryable: correctable,
|
|
158
|
+
reason: correctable ? CORRECT_REQUEST_ADVICE : DO_NOT_RETRY_ADVICE,
|
|
159
|
+
attemptedBackends: attempted,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// 视觉桥:收集消息里的图片、保存最近一张截图、调用辅助视觉模型并把结构化视觉笔记注入上下文。
|
|
2
|
+
// 文案(注入告警、询问失败说明)全部走 pi-toolkit 三语键表;纯文本模型才走注入,原生多模态模型旁路。
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Translator } from "../../i18n.ts";
|
|
6
|
+
import { VisionChain, type VisionAnswer } from "./chain.ts";
|
|
7
|
+
import { imageHash, noteKey, VisionNoteCache } from "./vision-cache.ts";
|
|
8
|
+
import { contextBlock, formatNote, isStructuredVisionResponse, neutralizeVisionDelimiters, notePrompt } from "./vision-prompt.ts";
|
|
9
|
+
import {
|
|
10
|
+
describeVisionImageError,
|
|
11
|
+
isSharpMissingError,
|
|
12
|
+
prepareVisionImages,
|
|
13
|
+
} from "./vision-preprocess.ts";
|
|
14
|
+
import type { VisionImage, VisionMessages, VisionNote, VisionResource } from "./vision-types.ts";
|
|
15
|
+
|
|
16
|
+
const CONTEXT_START = "<vision-context>";
|
|
17
|
+
const CONTEXT_END = "</vision-context>";
|
|
18
|
+
const TASK_DEADLINE_MS = 120_000;
|
|
19
|
+
const PER_CALL_MS = 120_000;
|
|
20
|
+
|
|
21
|
+
function contentText(content: unknown): string {
|
|
22
|
+
if (typeof content === "string") return content;
|
|
23
|
+
if (!Array.isArray(content)) return "";
|
|
24
|
+
return content.filter((block: any) => block?.type === "text").map((block: any) => block.text || "").join("\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTextOnlyModel(model: any): boolean {
|
|
28
|
+
return Array.isArray(model?.input) && !model.input.includes("image");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function modelQuestion(messages: VisionMessages): string {
|
|
32
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
33
|
+
if ((messages[index] as any)?.role !== "user") continue;
|
|
34
|
+
const text = contentText((messages[index] as any)?.content)
|
|
35
|
+
.replace(/<vision-context>[\s\S]*?<\/vision-context>/g, "")
|
|
36
|
+
.replace(/\s+/g, " ")
|
|
37
|
+
.trim();
|
|
38
|
+
if (text) return text.slice(0, 2000);
|
|
39
|
+
}
|
|
40
|
+
return "Describe the image accurately and report only visible evidence.";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function collectImages(messages: VisionMessages): VisionResource[] {
|
|
44
|
+
const resources: VisionResource[] = [];
|
|
45
|
+
messages.forEach((message: any, messageIndex) => {
|
|
46
|
+
if (contentText(message?.content).includes(CONTEXT_START)) return;
|
|
47
|
+
if (!Array.isArray(message?.content)) return;
|
|
48
|
+
message.content.forEach((block: any) => {
|
|
49
|
+
if (block?.type !== "image") return;
|
|
50
|
+
const image: VisionImage = {
|
|
51
|
+
data: block.data || block.source?.data,
|
|
52
|
+
mediaType: block.mimeType || block.mediaType || block.source?.mediaType || "image/png",
|
|
53
|
+
};
|
|
54
|
+
if (typeof image.data !== "string" || !image.data) return;
|
|
55
|
+
resources.push({ messageIndex, image, hash: imageHash(image) });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
return resources;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function removeImages(content: unknown): unknown {
|
|
62
|
+
if (!Array.isArray(content)) return content;
|
|
63
|
+
return content.filter((block: any) => block?.type !== "image");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function addText(content: unknown, text: string): unknown {
|
|
67
|
+
if (typeof content === "string") return `${text}${content}`;
|
|
68
|
+
if (Array.isArray(content)) return [{ type: "text", text }, ...content];
|
|
69
|
+
return [{ type: "text", text }];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lastUserMessageIndex(messages: VisionMessages): number {
|
|
73
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
74
|
+
if ((messages[index] as any)?.role === "user") return index;
|
|
75
|
+
}
|
|
76
|
+
return -1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function failureMessages(messages: VisionMessages, failure: string): VisionMessages {
|
|
80
|
+
const index = lastUserMessageIndex(messages);
|
|
81
|
+
return messages.map((message: any, messageIndex) => {
|
|
82
|
+
const content = removeImages(message.content);
|
|
83
|
+
return messageIndex === index
|
|
84
|
+
? { ...message, content: addText(content, `${CONTEXT_START}\n${neutralizeVisionDelimiters(failure)}\n${CONTEXT_END}\n\n`) }
|
|
85
|
+
: Array.isArray(message.content) && content !== message.content
|
|
86
|
+
? { ...message, content }
|
|
87
|
+
: message;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class VisionBridge {
|
|
92
|
+
private readonly cache = new VisionNoteCache();
|
|
93
|
+
private readonly chain: VisionChain;
|
|
94
|
+
private readonly t: Translator;
|
|
95
|
+
private readonly latestImages = new Map<string, VisionImage>();
|
|
96
|
+
private latestSessionKey?: string;
|
|
97
|
+
|
|
98
|
+
constructor(chain: VisionChain, t: Translator) {
|
|
99
|
+
this.chain = chain;
|
|
100
|
+
this.t = t;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private sessionKey(ctx: ExtensionContext): string {
|
|
104
|
+
return ctx.sessionManager.getSessionFile() || `cwd:${ctx.cwd || "unknown"}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private rememberLatest(ctx: ExtensionContext, image: VisionImage): void {
|
|
108
|
+
const key = this.sessionKey(ctx);
|
|
109
|
+
if (this.latestSessionKey && this.latestSessionKey !== key) this.latestImages.clear();
|
|
110
|
+
this.latestSessionKey = key;
|
|
111
|
+
this.latestImages.set(key, image);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** sharp 缺失属于可操作的安装问题,单独给用户弹提示(其余预处理失败只注入上下文) */
|
|
115
|
+
private reportPreprocessFailure(error: unknown, ctx: ExtensionContext): void {
|
|
116
|
+
if (!ctx.hasUI || !isSharpMissingError(error)) return;
|
|
117
|
+
ctx.ui.notify(describeVisionImageError(error, this.t), "error");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async analyze(image: VisionImage, question: string, ctx: ExtensionContext, deadlineAt: number): Promise<VisionNote | undefined> {
|
|
121
|
+
const currentModel = ctx.model ? { provider: ctx.model.provider, modelId: ctx.model.id } : undefined;
|
|
122
|
+
const target = this.chain.selectTarget(ctx.modelRegistry, currentModel);
|
|
123
|
+
const cacheModel = target
|
|
124
|
+
? { provider: target.provider, id: target.modelId }
|
|
125
|
+
: currentModel ? { provider: currentModel.provider, id: currentModel.modelId } : undefined;
|
|
126
|
+
const key = noteKey(imageHash(image), question, cacheModel);
|
|
127
|
+
const cached = this.cache.get(key);
|
|
128
|
+
if (cached) return cached;
|
|
129
|
+
|
|
130
|
+
const answer = await this.chain.ask(ctx.modelRegistry, [image], notePrompt(question), {
|
|
131
|
+
signal: ctx.signal,
|
|
132
|
+
deadlineAt,
|
|
133
|
+
currentModel,
|
|
134
|
+
perCallMs: PER_CALL_MS,
|
|
135
|
+
});
|
|
136
|
+
if (!answer.ok) return undefined;
|
|
137
|
+
|
|
138
|
+
let responseText = answer.text;
|
|
139
|
+
let correctionBackend = answer.backend;
|
|
140
|
+
if (!isStructuredVisionResponse(responseText)) {
|
|
141
|
+
const correction = await this.chain.ask(
|
|
142
|
+
ctx.modelRegistry,
|
|
143
|
+
[image],
|
|
144
|
+
`${notePrompt(question)}\n上一次输出格式无效。现在只返回完整 JSON,不要添加 Markdown 或解释。`,
|
|
145
|
+
{ signal: ctx.signal, deadlineAt, currentModel, perCallMs: PER_CALL_MS },
|
|
146
|
+
);
|
|
147
|
+
if (!correction.ok || !isStructuredVisionResponse(correction.text)) return undefined;
|
|
148
|
+
responseText = correction.text;
|
|
149
|
+
correctionBackend = correction.backend;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const note: VisionNote = {
|
|
153
|
+
key,
|
|
154
|
+
note: formatNote(responseText, question),
|
|
155
|
+
imageHash: imageHash(image),
|
|
156
|
+
question,
|
|
157
|
+
model: correctionBackend,
|
|
158
|
+
updatedAt: Date.now(),
|
|
159
|
+
};
|
|
160
|
+
await this.cache.set(ctx, note);
|
|
161
|
+
return note;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 让主模型针对最近一张截图自由追问辅助视觉模型。 */
|
|
165
|
+
async queryLatest(question: string, ctx: ExtensionContext): Promise<VisionAnswer> {
|
|
166
|
+
const normalizedQuestion = question.trim();
|
|
167
|
+
if (!normalizedQuestion) return { ok: false, json: this.t("module.eyes.queryEmpty") };
|
|
168
|
+
await this.cache.load(ctx);
|
|
169
|
+
const image = this.latestImages.get(this.sessionKey(ctx));
|
|
170
|
+
if (!image) return { ok: false, json: this.t("module.eyes.queryNoImage") };
|
|
171
|
+
let preparedImage: VisionImage;
|
|
172
|
+
try {
|
|
173
|
+
[preparedImage] = await prepareVisionImages([image]);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
this.reportPreprocessFailure(error, ctx);
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
json: this.t("module.eyes.injectPreprocessFailed", { reason: describeVisionImageError(error, this.t) }),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const note = await this.analyze(preparedImage, normalizedQuestion, ctx, Date.now() + TASK_DEADLINE_MS);
|
|
182
|
+
return note
|
|
183
|
+
? { ok: true, text: note.note, backend: note.model }
|
|
184
|
+
: { ok: false, json: this.t("module.eyes.queryFailed") };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async prepare(messages: VisionMessages, ctx: ExtensionContext): Promise<VisionMessages> {
|
|
188
|
+
const resources = collectImages(messages);
|
|
189
|
+
if (!resources.length) return messages;
|
|
190
|
+
if (!isTextOnlyModel(ctx.model)) {
|
|
191
|
+
this.rememberLatest(ctx, resources[resources.length - 1].image);
|
|
192
|
+
return messages;
|
|
193
|
+
}
|
|
194
|
+
await this.cache.load(ctx);
|
|
195
|
+
|
|
196
|
+
const question = modelQuestion(messages);
|
|
197
|
+
let preparedImages: VisionImage[];
|
|
198
|
+
try {
|
|
199
|
+
preparedImages = await prepareVisionImages(resources.map((resource) => resource.image));
|
|
200
|
+
} catch (error) {
|
|
201
|
+
this.reportPreprocessFailure(error, ctx);
|
|
202
|
+
return failureMessages(messages, describeVisionImageError(error, this.t));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this.rememberLatest(ctx, preparedImages[preparedImages.length - 1]);
|
|
206
|
+
const deadlineAt = Date.now() + TASK_DEADLINE_MS;
|
|
207
|
+
const notes: VisionNote[] = [];
|
|
208
|
+
for (let resourceIndex = 0; resourceIndex < resources.length; resourceIndex += 1) {
|
|
209
|
+
const note = await this.analyze(preparedImages[resourceIndex], question, ctx, deadlineAt);
|
|
210
|
+
if (!note) return failureMessages(messages, this.t("module.eyes.injectUnavailable"));
|
|
211
|
+
notes.push(note);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const byMessage = new Map<number, VisionNote[]>();
|
|
215
|
+
resources.forEach((resource, index) => {
|
|
216
|
+
const list = byMessage.get(resource.messageIndex) || [];
|
|
217
|
+
list.push(notes[index]);
|
|
218
|
+
byMessage.set(resource.messageIndex, list);
|
|
219
|
+
});
|
|
220
|
+
return messages.map((message: any, index) => {
|
|
221
|
+
const localNotes = byMessage.get(index);
|
|
222
|
+
return localNotes?.length
|
|
223
|
+
? { ...message, content: addText(removeImages(message.content), contextBlock(localNotes)) }
|
|
224
|
+
: message;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** context 注入钩子:只有模块启用时才会被注册(装配器负责按 enabled 跳过) */
|
|
230
|
+
export function registerVisionContext(pi: ExtensionAPI, bridge: VisionBridge): void {
|
|
231
|
+
pi.on("context", async (event, ctx) => ({ messages: await bridge.prepare(event.messages, ctx) }));
|
|
232
|
+
}
|