@pi-unipi/unipi 2.1.2 → 2.2.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/README.md +6 -2
- package/package.json +24 -23
- package/packages/ask-user/ask-ui.ts +45 -31
- package/packages/ask-user/launcher-ui.ts +26 -22
- package/packages/ask-user/package.json +3 -3
- package/packages/ask-user/settings-tui.ts +22 -9
- package/packages/autocomplete/package.json +1 -1
- package/packages/autocomplete/src/constants.ts +5 -0
- package/packages/btw/package.json +2 -2
- package/packages/cocoindex/package.json +2 -2
- package/packages/compactor/package.json +4 -4
- package/packages/compactor/src/tui/settings-overlay.ts +2 -1
- package/packages/core/constants.ts +18 -0
- package/packages/core/index.ts +1 -0
- package/packages/core/package.json +1 -1
- package/packages/core/tui-width.ts +127 -0
- package/packages/footer/package.json +3 -3
- package/packages/footer/src/tui/settings-tui.ts +2 -1
- package/packages/image/README.md +82 -0
- package/packages/image/index.ts +1 -0
- package/packages/image/package.json +57 -0
- package/packages/image/skills/image/SKILL.md +73 -0
- package/packages/image/src/commands.ts +17 -0
- package/packages/image/src/generate.ts +201 -0
- package/packages/image/src/image-source.ts +204 -0
- package/packages/image/src/index.ts +99 -0
- package/packages/image/src/models.ts +290 -0
- package/packages/image/src/recognize.ts +223 -0
- package/packages/image/src/settings.ts +149 -0
- package/packages/image/src/tools.ts +296 -0
- package/packages/image/src/tui/model-selector.ts +279 -0
- package/packages/image/src/tui/settings-dialog.ts +236 -0
- package/packages/info-screen/package.json +2 -2
- package/packages/info-screen/tui/info-overlay.ts +3 -2
- package/packages/input-shortcuts/package.json +3 -3
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/package.json +2 -2
- package/packages/mcp/src/tui/add-overlay.ts +10 -7
- package/packages/mcp/src/tui/settings-overlay.ts +10 -7
- package/packages/memory/package.json +3 -3
- package/packages/milestone/package.json +2 -2
- package/packages/notify/README.md +4 -1
- package/packages/notify/events.ts +130 -81
- package/packages/notify/package.json +3 -3
- package/packages/notify/permission-prompt-message.ts +95 -0
- package/packages/notify/settings.ts +2 -0
- package/packages/notify/skills/configure-notify/SKILL.md +31 -2
- package/packages/notify/src/__tests__/event-bus.test.ts +3 -2
- package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
- package/packages/notify/tui/gotify-setup.ts +2 -1
- package/packages/notify/tui/ntfy-setup.ts +2 -1
- package/packages/notify/tui/recap-model-selector.ts +2 -2
- package/packages/notify/tui/settings-overlay.ts +2 -1
- package/packages/notify/tui/telegram-setup.ts +2 -1
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +5 -5
- package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
- package/packages/subagents/src/index.ts +52 -10
- package/packages/unipi/index.ts +2 -0
- package/packages/updater/package.json +3 -3
- package/packages/updater/src/tui/changelog-overlay.ts +2 -2
- package/packages/updater/src/tui/readme-overlay.ts +2 -1
- package/packages/updater/src/tui/update-overlay.ts +2 -1
- package/packages/utility/package.json +2 -2
- package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
- package/packages/utility/src/tui/util-settings-tui.ts +2 -2
- package/packages/web-api/README.md +28 -11
- package/packages/web-api/package.json +8 -2
- package/packages/web-api/skills/web/SKILL.md +36 -9
- package/packages/web-api/src/index.ts +15 -0
- package/packages/web-api/src/providers/duckduckgo.ts +71 -27
- package/packages/web-api/src/providers/firecrawl.ts +1 -1
- package/packages/web-api/src/providers/jina-reader.ts +1 -1
- package/packages/web-api/src/providers/jina-search.ts +1 -1
- package/packages/web-api/src/providers/perplexity.ts +2 -2
- package/packages/web-api/src/providers/serpapi.ts +1 -1
- package/packages/web-api/src/providers/tavily.ts +1 -1
- package/packages/web-api/src/providers/wigolo-client.ts +196 -0
- package/packages/web-api/src/providers/wigolo.ts +117 -0
- package/packages/web-api/src/settings.ts +1 -0
- package/packages/web-api/src/tools.ts +93 -36
- package/packages/workflow/package.json +2 -2
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/image — Image input handling
|
|
3
|
+
*
|
|
4
|
+
* Accepts a local file path, a data: URL, or a raw base64 string and
|
|
5
|
+
* normalizes it to the `{ data, mimeType }` shape pi-ai expects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
11
|
+
export interface LoadedImage {
|
|
12
|
+
/** Base64-encoded image data (no data: prefix). */
|
|
13
|
+
data: string;
|
|
14
|
+
/** IANA media type. */
|
|
15
|
+
mimeType: string;
|
|
16
|
+
/** Where it came from, for the tool's result message. */
|
|
17
|
+
source: "file" | "data-url" | "base64";
|
|
18
|
+
/** Absolute path when loaded from disk. */
|
|
19
|
+
path?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Media types accepted by the vision APIs. */
|
|
23
|
+
export const SUPPORTED_MIME_TYPES = [
|
|
24
|
+
"image/png",
|
|
25
|
+
"image/jpeg",
|
|
26
|
+
"image/gif",
|
|
27
|
+
"image/webp",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
const EXTENSION_MIME: Record<string, string> = {
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".jpg": "image/jpeg",
|
|
33
|
+
".jpeg": "image/jpeg",
|
|
34
|
+
".jfif": "image/jpeg",
|
|
35
|
+
".gif": "image/gif",
|
|
36
|
+
".webp": "image/webp",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Magic-number signatures, checked before trusting a file extension. */
|
|
40
|
+
const SIGNATURES: Array<{ mimeType: string; test: (b: Buffer) => boolean }> = [
|
|
41
|
+
{
|
|
42
|
+
mimeType: "image/png",
|
|
43
|
+
test: (b) =>
|
|
44
|
+
b.length >= 8 &&
|
|
45
|
+
b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
|
|
46
|
+
b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
mimeType: "image/jpeg",
|
|
50
|
+
test: (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
mimeType: "image/gif",
|
|
54
|
+
test: (b) => b.length >= 6 && b.subarray(0, 6).toString("ascii").startsWith("GIF8"),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
mimeType: "image/webp",
|
|
58
|
+
test: (b) =>
|
|
59
|
+
b.length >= 12 &&
|
|
60
|
+
b.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
61
|
+
b.subarray(8, 12).toString("ascii") === "WEBP",
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** Infer a media type from a file extension. */
|
|
66
|
+
export function mimeTypeFromExtension(filePath: string): string | undefined {
|
|
67
|
+
return EXTENSION_MIME[path.extname(filePath).toLowerCase()];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Detect a media type from magic numbers. Authoritative over the extension. */
|
|
71
|
+
export function detectMimeType(buffer: Buffer): string | undefined {
|
|
72
|
+
return SIGNATURES.find((s) => s.test(buffer))?.mimeType;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Whether a media type is accepted by the vision APIs. */
|
|
76
|
+
export function isSupportedMimeType(mimeType: string): boolean {
|
|
77
|
+
return (SUPPORTED_MIME_TYPES as readonly string[]).includes(mimeType);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parse a `data:image/png;base64,...` URL. */
|
|
81
|
+
export function parseDataUrl(
|
|
82
|
+
input: string,
|
|
83
|
+
): { data: string; mimeType: string } | null {
|
|
84
|
+
const match = input.match(/^data:([^;,]+)(;[^,]*)?,(.*)$/s);
|
|
85
|
+
if (!match) return null;
|
|
86
|
+
const [, mimeType, params, payload] = match;
|
|
87
|
+
if (!params?.includes("base64")) return null;
|
|
88
|
+
return { data: payload.trim(), mimeType: mimeType.trim().toLowerCase() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether a string plausibly is raw base64 (and long enough to be an image). */
|
|
92
|
+
export function looksLikeBase64(input: string): boolean {
|
|
93
|
+
const compact = input.replace(/\s/g, "");
|
|
94
|
+
if (compact.length < 64) return false;
|
|
95
|
+
return /^[A-Za-z0-9+/]+={0,2}$/.test(compact);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function describeUnsupported(mimeType: string): string {
|
|
99
|
+
return (
|
|
100
|
+
`Unsupported image type "${mimeType}". ` +
|
|
101
|
+
`Supported types: ${SUPPORTED_MIME_TYPES.join(", ")}.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve an `image` parameter to base64 data plus a media type.
|
|
107
|
+
*
|
|
108
|
+
* Order: data: URL, then existing file path, then raw base64. A path that
|
|
109
|
+
* looks like a path but does not exist reports the missing file rather than
|
|
110
|
+
* being misread as base64.
|
|
111
|
+
*
|
|
112
|
+
* @throws {Error} with an actionable message on any unusable input.
|
|
113
|
+
*/
|
|
114
|
+
export function loadImage(input: string, cwd = process.cwd()): LoadedImage {
|
|
115
|
+
const trimmed = input.trim();
|
|
116
|
+
if (!trimmed) {
|
|
117
|
+
throw new Error("No image provided. Pass a file path, a data: URL, or base64 data.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 1. data: URL
|
|
121
|
+
if (trimmed.startsWith("data:")) {
|
|
122
|
+
const parsed = parseDataUrl(trimmed);
|
|
123
|
+
if (!parsed) {
|
|
124
|
+
throw new Error("Malformed data: URL — expected data:<mime>;base64,<data>.");
|
|
125
|
+
}
|
|
126
|
+
if (!isSupportedMimeType(parsed.mimeType)) {
|
|
127
|
+
throw new Error(describeUnsupported(parsed.mimeType));
|
|
128
|
+
}
|
|
129
|
+
return { data: parsed.data, mimeType: parsed.mimeType, source: "data-url" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Remote URLs are not fetched — be explicit rather than silently failing.
|
|
133
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
"Remote image URLs are not supported. " +
|
|
136
|
+
"Download the image first, then pass the local file path.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 3. File path
|
|
141
|
+
const looksLikePath =
|
|
142
|
+
trimmed.startsWith("/") ||
|
|
143
|
+
trimmed.startsWith("~") ||
|
|
144
|
+
trimmed.startsWith(".") ||
|
|
145
|
+
/[\\/]/.test(trimmed) ||
|
|
146
|
+
Boolean(mimeTypeFromExtension(trimmed));
|
|
147
|
+
|
|
148
|
+
if (looksLikePath) {
|
|
149
|
+
const resolved = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
|
150
|
+
|
|
151
|
+
if (!fs.existsSync(resolved)) {
|
|
152
|
+
throw new Error(`Image file not found: ${resolved}`);
|
|
153
|
+
}
|
|
154
|
+
if (!fs.statSync(resolved).isFile()) {
|
|
155
|
+
throw new Error(`Not a file: ${resolved}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const buffer = fs.readFileSync(resolved);
|
|
159
|
+
if (buffer.length === 0) {
|
|
160
|
+
throw new Error(`Image file is empty: ${resolved}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Trust the content over the extension.
|
|
164
|
+
const mimeType = detectMimeType(buffer) ?? mimeTypeFromExtension(resolved);
|
|
165
|
+
if (!mimeType) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Could not determine the image type of ${resolved}. ` +
|
|
168
|
+
`Supported types: ${SUPPORTED_MIME_TYPES.join(", ")}.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (!isSupportedMimeType(mimeType)) {
|
|
172
|
+
throw new Error(describeUnsupported(mimeType));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
data: buffer.toString("base64"),
|
|
177
|
+
mimeType,
|
|
178
|
+
source: "file",
|
|
179
|
+
path: resolved,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 4. Raw base64
|
|
184
|
+
if (looksLikeBase64(trimmed)) {
|
|
185
|
+
const compact = trimmed.replace(/\s/g, "");
|
|
186
|
+
const mimeType = detectMimeType(Buffer.from(compact, "base64"));
|
|
187
|
+
if (!mimeType) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
"Could not determine the image type of the supplied base64 data. " +
|
|
190
|
+
"Prefer a file path, or use a data: URL that declares the media type.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return { data: compact, mimeType, source: "base64" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Could not interpret "${truncate(trimmed)}" as an image. ` +
|
|
198
|
+
"Pass a local file path, a data: URL, or base64-encoded image data.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function truncate(value: string, max = 60): string {
|
|
203
|
+
return value.length <= max ? value : `${value.slice(0, max)}…`;
|
|
204
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/image — Extension entry
|
|
3
|
+
*
|
|
4
|
+
* Provides the `image_generate` and `image_recognize` agent tools plus the
|
|
5
|
+
* `/unipi:image-settings` command.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import {
|
|
12
|
+
IMAGE_COMMANDS,
|
|
13
|
+
IMAGE_TOOLS,
|
|
14
|
+
MODULES,
|
|
15
|
+
UNIPI_EVENTS,
|
|
16
|
+
UNIPI_PREFIX,
|
|
17
|
+
emitEvent,
|
|
18
|
+
getPackageVersion,
|
|
19
|
+
} from "@pi-unipi/core";
|
|
20
|
+
|
|
21
|
+
import { registerImageCommands } from "./commands.js";
|
|
22
|
+
import { registerImageTools } from "./tools.js";
|
|
23
|
+
import { listImageGenModels, listVisionModels, type ChatModelRegistry } from "./models.js";
|
|
24
|
+
import { loadConfig } from "./settings.js";
|
|
25
|
+
|
|
26
|
+
const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
|
|
27
|
+
|
|
28
|
+
/** Info-screen registry, read off the global to avoid load-order coupling. */
|
|
29
|
+
function getInfoRegistry() {
|
|
30
|
+
return (
|
|
31
|
+
globalThis as {
|
|
32
|
+
__unipi_info_registry?: {
|
|
33
|
+
registerGroup(group: unknown): void;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
).__unipi_info_registry;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function (pi: ExtensionAPI) {
|
|
40
|
+
registerImageTools(pi);
|
|
41
|
+
registerImageCommands(pi);
|
|
42
|
+
|
|
43
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
44
|
+
const config = loadConfig();
|
|
45
|
+
|
|
46
|
+
const tools: string[] = [];
|
|
47
|
+
if (config.generate.enabled) tools.push(IMAGE_TOOLS.GENERATE);
|
|
48
|
+
if (config.recognize.enabled) tools.push(IMAGE_TOOLS.RECOGNIZE);
|
|
49
|
+
|
|
50
|
+
emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
|
|
51
|
+
name: MODULES.IMAGE,
|
|
52
|
+
version: VERSION,
|
|
53
|
+
commands: [`${UNIPI_PREFIX}${IMAGE_COMMANDS.SETTINGS}`],
|
|
54
|
+
tools,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const registry = getInfoRegistry();
|
|
58
|
+
if (!registry) return;
|
|
59
|
+
|
|
60
|
+
registry.registerGroup({
|
|
61
|
+
id: "image",
|
|
62
|
+
name: "Image",
|
|
63
|
+
icon: "🎨",
|
|
64
|
+
priority: 55,
|
|
65
|
+
config: {
|
|
66
|
+
showByDefault: true,
|
|
67
|
+
stats: [
|
|
68
|
+
{ id: "generate", label: "Generate", show: true },
|
|
69
|
+
{ id: "recognize", label: "Recognize", show: true },
|
|
70
|
+
{ id: "visionModels", label: "Vision Models", show: true },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
dataProvider: async () => {
|
|
74
|
+
const current = loadConfig();
|
|
75
|
+
|
|
76
|
+
const genModels = await listImageGenModels();
|
|
77
|
+
const generate = current.generate.enabled
|
|
78
|
+
? genModels.length > 0
|
|
79
|
+
? current.generate.model
|
|
80
|
+
: "No image models available"
|
|
81
|
+
: "Disabled";
|
|
82
|
+
|
|
83
|
+
const chatRegistry = (ctx as unknown as { modelRegistry?: ChatModelRegistry })
|
|
84
|
+
.modelRegistry;
|
|
85
|
+
const vision = chatRegistry ? listVisionModels(chatRegistry) : [];
|
|
86
|
+
|
|
87
|
+
const recognize = current.recognize.enabled
|
|
88
|
+
? current.recognize.model || "Session model"
|
|
89
|
+
: "Disabled";
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
generate: { value: generate },
|
|
93
|
+
recognize: { value: recognize },
|
|
94
|
+
visionModels: { value: String(vision.length) },
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/image — Model discovery and resolution
|
|
3
|
+
*
|
|
4
|
+
* Two different model families are involved:
|
|
5
|
+
*
|
|
6
|
+
* - **Image generation** uses pi-ai's `ImagesModel` catalog, which
|
|
7
|
+
* pi-coding-agent does not expose on `ExtensionContext` — it is reached
|
|
8
|
+
* through the pi-ai subpath exports.
|
|
9
|
+
* - **Image recognition** uses ordinary chat models filtered to those whose
|
|
10
|
+
* `input` modality includes `"image"` (`ctx.modelRegistry`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A generation model, kept structural to avoid deep pi-ai type coupling. */
|
|
14
|
+
export interface ImageGenModel {
|
|
15
|
+
id: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
provider: string;
|
|
18
|
+
api: string;
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
input?: string[];
|
|
21
|
+
output?: string[];
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A vision-capable chat model. */
|
|
26
|
+
export interface VisionModel {
|
|
27
|
+
id: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
provider: string;
|
|
30
|
+
input?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Minimal chat-model registry surface (pi's ModelRegistry). */
|
|
34
|
+
export interface ChatModelRegistry {
|
|
35
|
+
find(provider: string, modelId: string): unknown;
|
|
36
|
+
getAll(): unknown[];
|
|
37
|
+
getAvailable?(): unknown[];
|
|
38
|
+
getApiKeyForProvider?(provider: string): Promise<string | undefined>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Split "provider/model-id" — the model id may itself contain slashes. */
|
|
42
|
+
export function splitModelRef(ref: string): { provider: string; id: string } | null {
|
|
43
|
+
const trimmed = ref.trim();
|
|
44
|
+
const slash = trimmed.indexOf("/");
|
|
45
|
+
if (slash <= 0 || slash === trimmed.length - 1) return null;
|
|
46
|
+
return { provider: trimmed.slice(0, slash), id: trimmed.slice(slash + 1) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Format a model as "provider/model-id". */
|
|
50
|
+
export function formatModelRef(model: { provider: string; id: string }): string {
|
|
51
|
+
return `${model.provider}/${model.id}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* pi-ai's runtime image-model collection: model catalog, auth resolution and
|
|
56
|
+
* generation in one object. Only the parts used here are typed.
|
|
57
|
+
*/
|
|
58
|
+
export interface ImagesModelsLike {
|
|
59
|
+
getModels(provider?: string): readonly ImageGenModel[];
|
|
60
|
+
getModel(provider: string, id: string): ImageGenModel | undefined;
|
|
61
|
+
getAuth(model: ImageGenModel): Promise<{ apiKey?: string } | undefined>;
|
|
62
|
+
generateImages(
|
|
63
|
+
model: ImageGenModel,
|
|
64
|
+
context: { input: Array<{ type: string; text?: string }> },
|
|
65
|
+
options?: { apiKey?: string; signal?: AbortSignal },
|
|
66
|
+
): Promise<unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cachedImagesModels: ImagesModelsLike | null = null;
|
|
70
|
+
let imagesModelsAttempted = false;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load pi-ai's built-in images collection.
|
|
74
|
+
*
|
|
75
|
+
* `getImageModels`/`generateImages` are not re-exported from the pi-ai package
|
|
76
|
+
* root, but `providers/all` exports `builtinImagesModels()`, which is the
|
|
77
|
+
* supported entry point and also resolves auth. A failure is not fatal: it
|
|
78
|
+
* degrades to null and callers report that no models are available.
|
|
79
|
+
*/
|
|
80
|
+
export async function getImagesModels(): Promise<ImagesModelsLike | null> {
|
|
81
|
+
if (cachedImagesModels || imagesModelsAttempted) return cachedImagesModels;
|
|
82
|
+
imagesModelsAttempted = true;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const mod = (await import("@earendil-works/pi-ai/providers/all")) as unknown as {
|
|
86
|
+
builtinImagesModels?: () => ImagesModelsLike;
|
|
87
|
+
};
|
|
88
|
+
if (typeof mod.builtinImagesModels === "function") {
|
|
89
|
+
cachedImagesModels = mod.builtinImagesModels();
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
cachedImagesModels = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return cachedImagesModels;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** List available image-generation models. Empty when unavailable. */
|
|
99
|
+
export async function listImageGenModels(): Promise<ImageGenModel[]> {
|
|
100
|
+
const images = await getImagesModels();
|
|
101
|
+
if (!images) return [];
|
|
102
|
+
try {
|
|
103
|
+
return [...images.getModels()];
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Inject a stub images collection. Test-only. */
|
|
110
|
+
export function __setImagesModelsForTests(models: ImagesModelsLike | null): void {
|
|
111
|
+
cachedImagesModels = models;
|
|
112
|
+
imagesModelsAttempted = models !== null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Reset the model cache. Test-only. */
|
|
116
|
+
export function __resetModelCacheForTests(): void {
|
|
117
|
+
cachedImagesModels = null;
|
|
118
|
+
imagesModelsAttempted = false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve a generation-model reference against the catalog.
|
|
123
|
+
*
|
|
124
|
+
* Exact "provider/id" first, then a scored fuzzy match so "flux" or
|
|
125
|
+
* "gemini-3-pro" work. Returns an error string (not a throw) so the tool can
|
|
126
|
+
* surface it as a normal tool error listing the alternatives.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveImageGenModel(
|
|
129
|
+
input: string,
|
|
130
|
+
models: ImageGenModel[],
|
|
131
|
+
): ImageGenModel | string {
|
|
132
|
+
const query = input.trim().toLowerCase();
|
|
133
|
+
if (!query) return "No image model specified.";
|
|
134
|
+
if (models.length === 0) {
|
|
135
|
+
return (
|
|
136
|
+
"No image generation models are available.\n" +
|
|
137
|
+
"→ Image generation requires an OpenRouter account: https://openrouter.ai/keys"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 1. Exact "provider/id"
|
|
142
|
+
const exact = models.find((m) => formatModelRef(m).toLowerCase() === query);
|
|
143
|
+
if (exact) return exact;
|
|
144
|
+
|
|
145
|
+
// 2. Exact id, ignoring the provider
|
|
146
|
+
const byId = models.find((m) => m.id.toLowerCase() === query);
|
|
147
|
+
if (byId) return byId;
|
|
148
|
+
|
|
149
|
+
// 3. Fuzzy
|
|
150
|
+
let best: ImageGenModel | undefined;
|
|
151
|
+
let bestScore = 0;
|
|
152
|
+
|
|
153
|
+
for (const model of models) {
|
|
154
|
+
const id = model.id.toLowerCase();
|
|
155
|
+
const full = formatModelRef(model).toLowerCase();
|
|
156
|
+
const name = (model.name ?? model.id).toLowerCase();
|
|
157
|
+
|
|
158
|
+
let score = 0;
|
|
159
|
+
if (id.includes(query) || full.includes(query)) {
|
|
160
|
+
score = 60 + (query.length / id.length) * 30;
|
|
161
|
+
} else if (name.includes(query)) {
|
|
162
|
+
score = 40 + (query.length / name.length) * 20;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (score > bestScore) {
|
|
166
|
+
bestScore = score;
|
|
167
|
+
best = model;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (best && bestScore > 0) return best;
|
|
172
|
+
|
|
173
|
+
const sample = models.slice(0, 10).map((m) => ` ${formatModelRef(m)}`).join("\n");
|
|
174
|
+
return (
|
|
175
|
+
`Unknown image model "${input}".\n` +
|
|
176
|
+
`Available models (${models.length} total):\n${sample}` +
|
|
177
|
+
(models.length > 10 ? "\n …run /unipi:image-settings to browse all" : "")
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* List vision-capable chat models — those accepting image input.
|
|
183
|
+
*
|
|
184
|
+
* `Model.input` is `("text" | "image")[]` in pi-ai. Models that do not declare
|
|
185
|
+
* the field are excluded rather than assumed capable, so a bad guess never
|
|
186
|
+
* produces a confusing API error.
|
|
187
|
+
*/
|
|
188
|
+
export function listVisionModels(registry: ChatModelRegistry): VisionModel[] {
|
|
189
|
+
let models: unknown[];
|
|
190
|
+
try {
|
|
191
|
+
models = registry.getAvailable?.() ?? registry.getAll();
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return models.filter(isVisionModel);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isVisionModel(model: unknown): model is VisionModel {
|
|
200
|
+
if (model === null || typeof model !== "object") return false;
|
|
201
|
+
const candidate = model as Partial<VisionModel>;
|
|
202
|
+
if (typeof candidate.id !== "string" || typeof candidate.provider !== "string") {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
return Array.isArray(candidate.input) && candidate.input.includes("image");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve a vision-model reference, restricted to image-capable models.
|
|
210
|
+
*
|
|
211
|
+
* Rejecting a text-only model here gives a much clearer message than letting
|
|
212
|
+
* the provider fail on an unexpected image part.
|
|
213
|
+
*/
|
|
214
|
+
export function resolveVisionModel(
|
|
215
|
+
input: string,
|
|
216
|
+
registry: ChatModelRegistry,
|
|
217
|
+
): VisionModel | string {
|
|
218
|
+
const vision = listVisionModels(registry);
|
|
219
|
+
|
|
220
|
+
if (vision.length === 0) {
|
|
221
|
+
return (
|
|
222
|
+
"No vision-capable models are configured.\n" +
|
|
223
|
+
"→ image_recognize needs a model that accepts image input " +
|
|
224
|
+
"(e.g. anthropic/claude-sonnet, openai/gpt-5, google/gemini-3-pro).\n" +
|
|
225
|
+
"→ Configure one with /model or /unipi:image-settings."
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const query = input.trim().toLowerCase();
|
|
230
|
+
if (!query) return "No model specified.";
|
|
231
|
+
|
|
232
|
+
const exact = vision.find((m) => formatModelRef(m).toLowerCase() === query);
|
|
233
|
+
if (exact) return exact;
|
|
234
|
+
|
|
235
|
+
const byId = vision.find((m) => m.id.toLowerCase() === query);
|
|
236
|
+
if (byId) return byId;
|
|
237
|
+
|
|
238
|
+
let best: VisionModel | undefined;
|
|
239
|
+
let bestScore = 0;
|
|
240
|
+
|
|
241
|
+
for (const model of vision) {
|
|
242
|
+
const id = model.id.toLowerCase();
|
|
243
|
+
const full = formatModelRef(model).toLowerCase();
|
|
244
|
+
const name = (model.name ?? model.id).toLowerCase();
|
|
245
|
+
|
|
246
|
+
let score = 0;
|
|
247
|
+
if (id.includes(query) || full.includes(query)) {
|
|
248
|
+
score = 60 + (query.length / id.length) * 30;
|
|
249
|
+
} else if (name.includes(query)) {
|
|
250
|
+
score = 40 + (query.length / name.length) * 20;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (score > bestScore) {
|
|
254
|
+
bestScore = score;
|
|
255
|
+
best = model;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (best && bestScore > 0) return best;
|
|
260
|
+
|
|
261
|
+
// A known model that simply cannot see gets a targeted message.
|
|
262
|
+
let all: unknown[] = [];
|
|
263
|
+
try {
|
|
264
|
+
all = registry.getAvailable?.() ?? registry.getAll();
|
|
265
|
+
} catch {
|
|
266
|
+
all = [];
|
|
267
|
+
}
|
|
268
|
+
const knownButBlind = all.some((m) => {
|
|
269
|
+
const candidate = m as Partial<VisionModel>;
|
|
270
|
+
if (typeof candidate.id !== "string" || typeof candidate.provider !== "string") {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
return (
|
|
274
|
+
formatModelRef(candidate as VisionModel).toLowerCase() === query ||
|
|
275
|
+
candidate.id.toLowerCase() === query
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (knownButBlind) {
|
|
280
|
+
return `Model "${input}" does not accept image input. Vision-capable models: ${vision
|
|
281
|
+
.slice(0, 5)
|
|
282
|
+
.map(formatModelRef)
|
|
283
|
+
.join(", ")}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
`Unknown model "${input}".\n` +
|
|
288
|
+
`Vision-capable models: ${vision.map(formatModelRef).join(", ")}`
|
|
289
|
+
);
|
|
290
|
+
}
|