@pi-unipi/image 2.2.3 → 2.2.5
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/generate.ts +36 -0
- package/src/models.ts +17 -0
- package/src/tui/model-selector.ts +59 -36
- package/src/tui/settings-dialog.ts +15 -1
package/package.json
CHANGED
package/src/generate.ts
CHANGED
|
@@ -103,6 +103,26 @@ export function saveImage(
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Providers pi-ai's images collection can actually generate with. */
|
|
107
|
+
function supportedProviders(imagesApi: ImagesModelsLike): string[] {
|
|
108
|
+
try {
|
|
109
|
+
return [...new Set(imagesApi.getModels().map((m) => m.provider))];
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether generation can route to a provider. Unknown/empty catalogs are
|
|
117
|
+
* treated as capable so a stubbed or future pi-ai is never blocked by this
|
|
118
|
+
* check — the real call still reports its own error.
|
|
119
|
+
*/
|
|
120
|
+
function providerCanGenerate(imagesApi: ImagesModelsLike, provider: string): boolean {
|
|
121
|
+
const providers = supportedProviders(imagesApi);
|
|
122
|
+
if (providers.length === 0) return true;
|
|
123
|
+
return providers.includes(provider);
|
|
124
|
+
}
|
|
125
|
+
|
|
106
126
|
export interface GenerateOptions {
|
|
107
127
|
prompt: string;
|
|
108
128
|
model: ImageGenModel;
|
|
@@ -134,6 +154,22 @@ export async function generateImage(options: GenerateOptions): Promise<GenerateR
|
|
|
134
154
|
);
|
|
135
155
|
}
|
|
136
156
|
|
|
157
|
+
// pi-ai's images collection carries its own provider set (currently only
|
|
158
|
+
// `openrouter`) and is entirely separate from pi's chat model registry.
|
|
159
|
+
// A chat provider registered by another extension can therefore list image
|
|
160
|
+
// models that generation cannot actually drive — pi-ai answers with a bare
|
|
161
|
+
// "Unknown provider: x". Detect that here and say something useful.
|
|
162
|
+
if (!providerCanGenerate(imagesApi, model.provider)) {
|
|
163
|
+
const supported = supportedProviders(imagesApi);
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Provider "${model.provider}" cannot generate images.\n` +
|
|
166
|
+
`→ Image generation is served by: ${supported.join(", ") || "openrouter"}.\n` +
|
|
167
|
+
`→ "${model.provider}" is a chat provider; its image models are listed for ` +
|
|
168
|
+
`recognition and reference, but generation must go through a supported provider.\n` +
|
|
169
|
+
"→ Pick one with /unipi:image-settings.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
137
173
|
// Prefer pi-ai's own credential store, then the caller-supplied fallback so
|
|
138
174
|
// a bare OPENROUTER_API_KEY still works.
|
|
139
175
|
let apiKey: string | undefined;
|
package/src/models.ts
CHANGED
|
@@ -105,6 +105,23 @@ export async function getImagesModels(): Promise<ImagesModelsLike | null> {
|
|
|
105
105
|
return cachedImagesModels;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Providers pi-ai's images collection can actually generate with.
|
|
110
|
+
*
|
|
111
|
+
* This is NOT the same set as pi's chat model registry: a chat provider
|
|
112
|
+
* registered by another extension may list image models that image generation
|
|
113
|
+
* cannot drive. Empty means "unknown", which callers treat as permissive.
|
|
114
|
+
*/
|
|
115
|
+
export async function getGeneratingProviders(): Promise<string[]> {
|
|
116
|
+
const images = await getImagesModels();
|
|
117
|
+
if (!images) return [];
|
|
118
|
+
try {
|
|
119
|
+
return [...new Set(images.getModels().map((m) => m.provider))];
|
|
120
|
+
} catch {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
108
125
|
/** List available image-generation models. Empty when unavailable. */
|
|
109
126
|
export async function listImageGenModels(): Promise<ImageGenModel[]> {
|
|
110
127
|
const images = await getImagesModels();
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Component } from "@earendil-works/pi-tui";
|
|
9
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
10
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { boxInnerWidth, safeRepeat } from "@pi-unipi/core";
|
|
12
12
|
|
|
@@ -14,6 +14,8 @@ export interface SelectableModel {
|
|
|
14
14
|
provider: string;
|
|
15
15
|
id: string;
|
|
16
16
|
name?: string;
|
|
17
|
+
/** Set when the model is listed but not usable, with the reason. */
|
|
18
|
+
unavailable?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export type ModelSelectorKind = "generate" | "recognize";
|
|
@@ -61,7 +63,7 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
61
63
|
handleInput(data: string): void {
|
|
62
64
|
// Ctrl+C must always escape, even mid-filter. Without this the overlay traps
|
|
63
65
|
// the user with no way out.
|
|
64
|
-
if (data
|
|
66
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
65
67
|
this.onClose?.();
|
|
66
68
|
return;
|
|
67
69
|
}
|
|
@@ -76,49 +78,61 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
76
78
|
return;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
81
|
+
// Escape is checked via matchesKey, not `data === "\x1b"`: under the kitty
|
|
82
|
+
// keyboard protocol it arrives as "\x1b[27u" (and as "\x1b[27;1;27~" with
|
|
83
|
+
// modifyOtherKeys), so a bare comparison silently fails to close.
|
|
84
|
+
if (matchesKey(data, "escape")) {
|
|
85
|
+
this.onClose?.();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (matchesKey(data, "up") || data === "k") {
|
|
89
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (matchesKey(data, "down") || data === "j") {
|
|
93
|
+
this.selectedIndex = Math.min(this.filtered.length - 1, this.selectedIndex + 1);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (matchesKey(data, "enter")) {
|
|
97
|
+
this.commit();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (data === "/") {
|
|
101
|
+
this.filterMode = true;
|
|
102
|
+
this.filter = "";
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (data === "c" || data === "C") {
|
|
106
|
+
// Escape hatch: generator detection is heuristic, so a provider may
|
|
107
|
+
// expose a model the catalog cannot recognise. Let the user name it.
|
|
108
|
+
this.customMode = true;
|
|
109
|
+
this.custom = "";
|
|
110
|
+
this.error = null;
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
private handleFilterInput(data: string): void {
|
|
110
|
-
if (data
|
|
115
|
+
if (matchesKey(data, "enter")) {
|
|
111
116
|
this.filterMode = false;
|
|
112
117
|
return;
|
|
113
118
|
}
|
|
114
|
-
if (data
|
|
119
|
+
if (matchesKey(data, "escape")) {
|
|
115
120
|
this.filter = "";
|
|
116
121
|
this.filterMode = false;
|
|
117
122
|
this.applyFilter();
|
|
118
123
|
this.selectedIndex = 0;
|
|
119
124
|
return;
|
|
120
125
|
}
|
|
121
|
-
|
|
126
|
+
// Let the list be navigated without leaving the filter.
|
|
127
|
+
if (matchesKey(data, "up")) {
|
|
128
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (matchesKey(data, "down")) {
|
|
132
|
+
this.selectedIndex = Math.min(this.filtered.length - 1, this.selectedIndex + 1);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (matchesKey(data, "backspace") || data === "\x7f" || data === "\b") {
|
|
122
136
|
this.filter = this.filter.slice(0, -1);
|
|
123
137
|
this.applyFilter();
|
|
124
138
|
this.clampSelection();
|
|
@@ -133,17 +147,17 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
133
147
|
|
|
134
148
|
/** Free-text "provider/model-id" entry. */
|
|
135
149
|
private handleCustomInput(data: string): void {
|
|
136
|
-
if (data
|
|
150
|
+
if (matchesKey(data, "enter")) {
|
|
137
151
|
this.commitCustom();
|
|
138
152
|
return;
|
|
139
153
|
}
|
|
140
|
-
if (data
|
|
154
|
+
if (matchesKey(data, "escape")) {
|
|
141
155
|
this.customMode = false;
|
|
142
156
|
this.custom = "";
|
|
143
157
|
this.error = null;
|
|
144
158
|
return;
|
|
145
159
|
}
|
|
146
|
-
if (data === "\x7f" || data === "\b") {
|
|
160
|
+
if (matchesKey(data, "backspace") || data === "\x7f" || data === "\b") {
|
|
147
161
|
this.custom = this.custom.slice(0, -1);
|
|
148
162
|
this.error = null;
|
|
149
163
|
return;
|
|
@@ -200,6 +214,12 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
200
214
|
this.error = "No model selected";
|
|
201
215
|
return;
|
|
202
216
|
}
|
|
217
|
+
// Require a deliberate second Enter on a model that cannot be used, rather
|
|
218
|
+
// than silently saving a choice that will fail at call time.
|
|
219
|
+
if (model.unavailable && this.error === null) {
|
|
220
|
+
this.error = `${model.id} ${model.unavailable} — press Enter again to select anyway`;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
203
223
|
|
|
204
224
|
this.onSelect?.(`${model.provider}/${model.id}`);
|
|
205
225
|
this.saved = true;
|
|
@@ -323,9 +343,12 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
323
343
|
const marker = isSelected ? this.fg("accent", "▸") : " ";
|
|
324
344
|
const label = model.name || model.id;
|
|
325
345
|
const providerTag = this.fg("dim", `[${model.provider}]`);
|
|
326
|
-
const
|
|
346
|
+
const base = isSelected
|
|
327
347
|
? `${providerTag} ${this.bold(label)}`
|
|
328
348
|
: `${providerTag} ${this.fg("dim", label)}`;
|
|
349
|
+
const display = model.unavailable
|
|
350
|
+
? `${base} ${this.fg("warning", `(${model.unavailable})`)}`
|
|
351
|
+
: base;
|
|
329
352
|
lines.push(this.frameLine(` ${marker} ${display}`, innerWidth));
|
|
330
353
|
}
|
|
331
354
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../settings.js";
|
|
18
18
|
import {
|
|
19
19
|
formatModelRef,
|
|
20
|
+
getGeneratingProviders,
|
|
20
21
|
listAllImageGenModels,
|
|
21
22
|
listVisionModels,
|
|
22
23
|
type ChatModelRegistry,
|
|
@@ -241,7 +242,20 @@ async function collectModels(
|
|
|
241
242
|
// Include models from providers registered by other extensions, not just
|
|
242
243
|
// pi-ai's built-in OpenRouter catalog.
|
|
243
244
|
const models = await listAllImageGenModels(registry);
|
|
244
|
-
|
|
245
|
+
const generating = await getGeneratingProviders();
|
|
246
|
+
|
|
247
|
+
return models.map((m) => ({
|
|
248
|
+
provider: m.provider,
|
|
249
|
+
id: m.id,
|
|
250
|
+
name: m.name,
|
|
251
|
+
// pi-ai's images collection has its own provider set. A chat provider's
|
|
252
|
+
// image models are listed for reference but cannot actually generate,
|
|
253
|
+
// so flag them rather than letting the user pick a dead option.
|
|
254
|
+
unavailable:
|
|
255
|
+
generating.length > 0 && !generating.includes(m.provider)
|
|
256
|
+
? "cannot generate"
|
|
257
|
+
: undefined,
|
|
258
|
+
}));
|
|
245
259
|
}
|
|
246
260
|
|
|
247
261
|
if (!registry) return [];
|