@pi-unipi/image 2.2.1 → 2.2.3
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 +14 -4
- package/package.json +1 -1
- package/skills/image/SKILL.md +4 -1
- package/src/models.ts +49 -3
- package/src/recognize.ts +91 -6
- package/src/tui/model-selector.ts +85 -5
- package/src/tui/settings-dialog.ts +9 -8
package/README.md
CHANGED
|
@@ -22,10 +22,20 @@ image_generate(prompt: "A cutaway diagram of a submarine, technical illustration
|
|
|
22
22
|
image_generate(prompt: "...", model: "flux.2-pro")
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Models come from
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
Models come from two places, merged into one list:
|
|
26
|
+
|
|
27
|
+
- pi-ai's built-in image catalog — 34 models including FLUX.2, Gemini 3 Pro
|
|
28
|
+
Image, GPT-5 Image, Recraft and Riverflow — served through **OpenRouter**
|
|
29
|
+
([get a key](https://openrouter.ai/keys)).
|
|
30
|
+
- **Any provider registered by another extension.** Third-party providers
|
|
31
|
+
publish no image metadata, so these are detected by name; a real registry
|
|
32
|
+
with 393 models contributed 12 generators (FLUX, Nano Banana, Ideogram,
|
|
33
|
+
Recraft, Seedream, Stable Diffusion) alongside the built-ins.
|
|
34
|
+
|
|
35
|
+
Because that detection is heuristic, the picker has a **custom entry** — press
|
|
36
|
+
`c` in `/unipi:image-settings` and type any `provider/model-id`. A
|
|
37
|
+
well-formed reference is always accepted, even when the catalog has never
|
|
38
|
+
heard of it, so no model is ever unreachable.
|
|
29
39
|
|
|
30
40
|
The `model` parameter is fuzzy-matched, so `flux`, `recraft` and
|
|
31
41
|
`gemini-3-pro` all work. Omit it to use the model chosen in
|
package/package.json
CHANGED
package/skills/image/SKILL.md
CHANGED
|
@@ -63,8 +63,11 @@ magic numbers, so a misnamed extension still works.
|
|
|
63
63
|
|
|
64
64
|
`/unipi:image-settings` configures both tools:
|
|
65
65
|
|
|
66
|
-
- Generation model (picker over the image
|
|
66
|
+
- Generation model (picker over the built-in catalog plus image models from
|
|
67
|
+
any provider registered by another extension)
|
|
67
68
|
- Recognition model (picker over vision-capable models only)
|
|
69
|
+
- Either picker accepts a **custom** `provider/model-id` — press `c` — for a
|
|
70
|
+
model the catalog does not list
|
|
68
71
|
- Enable/disable either tool
|
|
69
72
|
- Output directory and whether to save to disk
|
|
70
73
|
- The recognition system prompt
|
package/src/models.ts
CHANGED
|
@@ -244,12 +244,18 @@ export function resolveImageGenModel(
|
|
|
244
244
|
input: string,
|
|
245
245
|
models: ImageGenModel[],
|
|
246
246
|
): ImageGenModel | string {
|
|
247
|
-
const
|
|
247
|
+
const raw = input.trim();
|
|
248
|
+
const query = raw.toLowerCase();
|
|
248
249
|
if (!query) return "No image model specified.";
|
|
249
250
|
if (models.length === 0) {
|
|
251
|
+
// A fully-qualified reference still works: detection is heuristic, so the
|
|
252
|
+
// user must be able to name a model we failed to discover.
|
|
253
|
+
const explicit = asExplicitModelRef(raw);
|
|
254
|
+
if (explicit) return explicit;
|
|
250
255
|
return (
|
|
251
256
|
"No image generation models are available.\n" +
|
|
252
|
-
"→ Image generation requires an OpenRouter account: https://openrouter.ai/keys"
|
|
257
|
+
"→ Image generation requires an OpenRouter account: https://openrouter.ai/keys\n" +
|
|
258
|
+
"→ Or set an exact model with /unipi:image-settings (press c to enter one manually)."
|
|
253
259
|
);
|
|
254
260
|
}
|
|
255
261
|
|
|
@@ -285,6 +291,11 @@ export function resolveImageGenModel(
|
|
|
285
291
|
|
|
286
292
|
if (best && bestScore > 0) return best;
|
|
287
293
|
|
|
294
|
+
// Nothing matched, but an explicit "provider/model-id" is taken at face
|
|
295
|
+
// value — the catalog is not authoritative for third-party providers.
|
|
296
|
+
const explicit = asExplicitModelRef(raw);
|
|
297
|
+
if (explicit) return explicit;
|
|
298
|
+
|
|
288
299
|
const sample = models.slice(0, 10).map((m) => ` ${formatModelRef(m)}`).join("\n");
|
|
289
300
|
return (
|
|
290
301
|
`Unknown image model "${input}".\n` +
|
|
@@ -293,6 +304,22 @@ export function resolveImageGenModel(
|
|
|
293
304
|
);
|
|
294
305
|
}
|
|
295
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Treat a well-formed "provider/model-id" as a usable model even when it is
|
|
309
|
+
* absent from the catalog.
|
|
310
|
+
*
|
|
311
|
+
* Generator detection is heuristic and third-party providers publish no image
|
|
312
|
+
* metadata, so refusing an unknown-but-well-formed reference would make some
|
|
313
|
+
* models permanently unreachable. Requiring the provider segment keeps this
|
|
314
|
+
* from swallowing plain typos, which still get the "Unknown image model" list.
|
|
315
|
+
*/
|
|
316
|
+
function asExplicitModelRef(raw: string): ImageGenModel | null {
|
|
317
|
+
const parts = splitModelRef(raw);
|
|
318
|
+
if (!parts) return null;
|
|
319
|
+
if (/\s/.test(raw)) return null;
|
|
320
|
+
return { id: parts.id, provider: parts.provider, api: "" };
|
|
321
|
+
}
|
|
322
|
+
|
|
296
323
|
/**
|
|
297
324
|
* List vision-capable chat models — those accepting image input.
|
|
298
325
|
*
|
|
@@ -331,8 +358,13 @@ export function resolveVisionModel(
|
|
|
331
358
|
registry: ChatModelRegistry,
|
|
332
359
|
): VisionModel | string {
|
|
333
360
|
const vision = listVisionModels(registry);
|
|
361
|
+
const raw = input.trim();
|
|
334
362
|
|
|
335
363
|
if (vision.length === 0) {
|
|
364
|
+
// Accept an explicit reference so a provider we cannot introspect is still
|
|
365
|
+
// usable (mirrors resolveImageGenModel).
|
|
366
|
+
const explicit = asExplicitVisionRef(raw);
|
|
367
|
+
if (explicit) return explicit;
|
|
336
368
|
return (
|
|
337
369
|
"No vision-capable models are configured.\n" +
|
|
338
370
|
"→ image_recognize needs a model that accepts image input " +
|
|
@@ -341,7 +373,7 @@ export function resolveVisionModel(
|
|
|
341
373
|
);
|
|
342
374
|
}
|
|
343
375
|
|
|
344
|
-
const query =
|
|
376
|
+
const query = raw.toLowerCase();
|
|
345
377
|
if (!query) return "No model specified.";
|
|
346
378
|
|
|
347
379
|
const exact = vision.find((m) => formatModelRef(m).toLowerCase() === query);
|
|
@@ -398,8 +430,22 @@ export function resolveVisionModel(
|
|
|
398
430
|
.join(", ")}`;
|
|
399
431
|
}
|
|
400
432
|
|
|
433
|
+
// Unknown to the registry, but well-formed — accept it. Checked after
|
|
434
|
+
// `knownButBlind` so a registered text-only model still gets the precise
|
|
435
|
+
// "does not accept image input" error rather than being waved through.
|
|
436
|
+
const explicit = asExplicitVisionRef(raw);
|
|
437
|
+
if (explicit) return explicit;
|
|
438
|
+
|
|
401
439
|
return (
|
|
402
440
|
`Unknown model "${input}".\n` +
|
|
403
441
|
`Vision-capable models: ${vision.map(formatModelRef).join(", ")}`
|
|
404
442
|
);
|
|
405
443
|
}
|
|
444
|
+
|
|
445
|
+
/** Accept a well-formed "provider/model-id" the registry does not know. */
|
|
446
|
+
function asExplicitVisionRef(raw: string): VisionModel | null {
|
|
447
|
+
const parts = splitModelRef(raw);
|
|
448
|
+
if (!parts) return null;
|
|
449
|
+
if (/\s/.test(raw)) return null;
|
|
450
|
+
return { id: parts.id, provider: parts.provider, input: ["text", "image"] };
|
|
451
|
+
}
|
package/src/recognize.ts
CHANGED
|
@@ -100,6 +100,7 @@ async function callAnthropic(options: RecognizeOptions): Promise<string> {
|
|
|
100
100
|
body: JSON.stringify({
|
|
101
101
|
model: modelId,
|
|
102
102
|
max_tokens: maxTokens,
|
|
103
|
+
stream: false,
|
|
103
104
|
system: systemPrompt,
|
|
104
105
|
messages: [
|
|
105
106
|
{
|
|
@@ -123,11 +124,14 @@ async function callAnthropic(options: RecognizeOptions): Promise<string> {
|
|
|
123
124
|
|
|
124
125
|
if (!response.ok) throw new Error(await describeHttpError(response));
|
|
125
126
|
|
|
126
|
-
const data =
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
const data = await readJsonOrStream(response);
|
|
128
|
+
|
|
129
|
+
// A streamed response arrives as deltas rather than a `content` array.
|
|
130
|
+
const streamed = collectStreamedText(data);
|
|
131
|
+
if (streamed !== null) return streamed;
|
|
129
132
|
|
|
130
|
-
|
|
133
|
+
const typed = data as { content?: Array<{ type?: string; text?: string }> };
|
|
134
|
+
return (typed.content ?? [])
|
|
131
135
|
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
132
136
|
.map((block) => block.text as string)
|
|
133
137
|
.join("\n")
|
|
@@ -137,6 +141,79 @@ async function callAnthropic(options: RecognizeOptions): Promise<string> {
|
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Read a response body as JSON, tolerating a Server-Sent Events stream.
|
|
146
|
+
*
|
|
147
|
+
* Some OpenAI-compatible gateways (omniroute, for one) reply with
|
|
148
|
+
* `text/event-stream` even when streaming was never requested. Calling
|
|
149
|
+
* `response.json()` on that throws `Unexpected token 'd', "data: {"id"...`,
|
|
150
|
+
* which tells the user nothing. Parse the SSE frames instead and hand back a
|
|
151
|
+
* synthetic payload carrying the concatenated deltas.
|
|
152
|
+
*/
|
|
153
|
+
async function readJsonOrStream(response: Response): Promise<unknown> {
|
|
154
|
+
const body = await response.text();
|
|
155
|
+
const trimmed = body.trimStart();
|
|
156
|
+
|
|
157
|
+
if (!trimmed.startsWith("data:")) {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(body) as unknown;
|
|
160
|
+
} catch {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`The model returned a response that could not be parsed:\n${body.slice(0, 200)}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const parts: string[] = [];
|
|
168
|
+
for (const line of body.split(/\r?\n/)) {
|
|
169
|
+
if (!line.startsWith("data:")) continue;
|
|
170
|
+
const payload = line.slice(5).trim();
|
|
171
|
+
if (!payload || payload === "[DONE]") continue;
|
|
172
|
+
|
|
173
|
+
let frame: unknown;
|
|
174
|
+
try {
|
|
175
|
+
frame = JSON.parse(payload);
|
|
176
|
+
} catch {
|
|
177
|
+
continue; // Ignore a partial or malformed frame rather than failing.
|
|
178
|
+
}
|
|
179
|
+
parts.push(...extractDeltaText(frame));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { __streamedText: parts.join("") };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Pull text out of one SSE frame, in both OpenAI and Anthropic shapes. */
|
|
186
|
+
function extractDeltaText(frame: unknown): string[] {
|
|
187
|
+
if (frame === null || typeof frame !== "object") return [];
|
|
188
|
+
const out: string[] = [];
|
|
189
|
+
|
|
190
|
+
// OpenAI: choices[].delta.content (or a non-streamed message.content)
|
|
191
|
+
const choices = (frame as { choices?: unknown }).choices;
|
|
192
|
+
if (Array.isArray(choices)) {
|
|
193
|
+
for (const choice of choices) {
|
|
194
|
+
if (choice === null || typeof choice !== "object") continue;
|
|
195
|
+
const delta = (choice as { delta?: { content?: unknown } }).delta;
|
|
196
|
+
if (typeof delta?.content === "string") out.push(delta.content);
|
|
197
|
+
const message = (choice as { message?: { content?: unknown } }).message;
|
|
198
|
+
if (typeof message?.content === "string") out.push(message.content);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Anthropic: content_block_delta → delta.text
|
|
203
|
+
const delta = (frame as { delta?: { text?: unknown } }).delta;
|
|
204
|
+
if (typeof delta?.text === "string") out.push(delta.text);
|
|
205
|
+
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Text collected from a streamed body, or null when it was ordinary JSON. */
|
|
210
|
+
function collectStreamedText(data: unknown): string | null {
|
|
211
|
+
if (data === null || typeof data !== "object") return null;
|
|
212
|
+
const streamed = (data as { __streamedText?: unknown }).__streamedText;
|
|
213
|
+
if (typeof streamed !== "string") return null;
|
|
214
|
+
return streamed.trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
140
217
|
/** OpenAI-compatible chat completions — image parts use a data: URL. */
|
|
141
218
|
async function callOpenAICompatible(options: RecognizeOptions): Promise<string> {
|
|
142
219
|
const {
|
|
@@ -159,6 +236,9 @@ async function callOpenAICompatible(options: RecognizeOptions): Promise<string>
|
|
|
159
236
|
body: JSON.stringify({
|
|
160
237
|
model: modelId,
|
|
161
238
|
max_tokens: maxTokens,
|
|
239
|
+
// Ask for a single payload. Gateways may stream regardless, which
|
|
240
|
+
// readJsonOrStream handles.
|
|
241
|
+
stream: false,
|
|
162
242
|
messages: [
|
|
163
243
|
{ role: "system", content: systemPrompt },
|
|
164
244
|
{
|
|
@@ -181,11 +261,16 @@ async function callOpenAICompatible(options: RecognizeOptions): Promise<string>
|
|
|
181
261
|
|
|
182
262
|
if (!response.ok) throw new Error(await describeHttpError(response));
|
|
183
263
|
|
|
184
|
-
const data =
|
|
264
|
+
const data = await readJsonOrStream(response);
|
|
265
|
+
|
|
266
|
+
const streamed = collectStreamedText(data);
|
|
267
|
+
if (streamed !== null) return streamed;
|
|
268
|
+
|
|
269
|
+
const typed = data as {
|
|
185
270
|
choices?: Array<{ message?: { content?: string | Array<{ text?: string }> } }>;
|
|
186
271
|
};
|
|
187
272
|
|
|
188
|
-
const content =
|
|
273
|
+
const content = typed.choices?.[0]?.message?.content;
|
|
189
274
|
if (typeof content === "string") return content.trim();
|
|
190
275
|
if (Array.isArray(content)) {
|
|
191
276
|
return content.map((part) => part?.text ?? "").join("").trim();
|
|
@@ -28,6 +28,9 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
28
28
|
private saved = false;
|
|
29
29
|
private error: string | null = null;
|
|
30
30
|
private theme: Theme | null = null;
|
|
31
|
+
/** Free-text entry, for models the catalog does not know about. */
|
|
32
|
+
private customMode = false;
|
|
33
|
+
private custom = "";
|
|
31
34
|
|
|
32
35
|
onClose?: () => void;
|
|
33
36
|
onSelect?: (modelRef: string) => void;
|
|
@@ -63,6 +66,11 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
63
66
|
return;
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
if (this.customMode) {
|
|
70
|
+
this.handleCustomInput(data);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
if (this.filterMode) {
|
|
67
75
|
this.handleFilterInput(data);
|
|
68
76
|
return;
|
|
@@ -81,6 +89,14 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
81
89
|
this.filterMode = true;
|
|
82
90
|
this.filter = "";
|
|
83
91
|
break;
|
|
92
|
+
case "c":
|
|
93
|
+
case "C":
|
|
94
|
+
// Escape hatch: generator detection is heuristic, so a provider may
|
|
95
|
+
// expose a model the catalog cannot recognise. Let the user name it.
|
|
96
|
+
this.customMode = true;
|
|
97
|
+
this.custom = "";
|
|
98
|
+
this.error = null;
|
|
99
|
+
break;
|
|
84
100
|
case "\r":
|
|
85
101
|
this.commit();
|
|
86
102
|
break;
|
|
@@ -115,6 +131,50 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
115
131
|
}
|
|
116
132
|
}
|
|
117
133
|
|
|
134
|
+
/** Free-text "provider/model-id" entry. */
|
|
135
|
+
private handleCustomInput(data: string): void {
|
|
136
|
+
if (data === "\r") {
|
|
137
|
+
this.commitCustom();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (data === "\x1b") {
|
|
141
|
+
this.customMode = false;
|
|
142
|
+
this.custom = "";
|
|
143
|
+
this.error = null;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (data === "\x7f" || data === "\b") {
|
|
147
|
+
this.custom = this.custom.slice(0, -1);
|
|
148
|
+
this.error = null;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (data.length === 1 && data >= " ") {
|
|
152
|
+
this.custom += data;
|
|
153
|
+
this.error = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private commitCustom(): void {
|
|
158
|
+
const ref = this.custom.trim();
|
|
159
|
+
if (!ref) {
|
|
160
|
+
this.error = "Enter a model as provider/model-id";
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Validate here rather than letting a malformed ref fail later with an
|
|
164
|
+
// opaque provider error.
|
|
165
|
+
const slash = ref.indexOf("/");
|
|
166
|
+
if (slash <= 0 || slash === ref.length - 1) {
|
|
167
|
+
this.error = `"${ref}" must be in the form provider/model-id`;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.customMode = false;
|
|
172
|
+
this.onSelect?.(ref);
|
|
173
|
+
this.saved = true;
|
|
174
|
+
this.error = null;
|
|
175
|
+
this.onClose?.();
|
|
176
|
+
}
|
|
177
|
+
|
|
118
178
|
private clampSelection(): void {
|
|
119
179
|
this.selectedIndex = Math.min(
|
|
120
180
|
this.selectedIndex,
|
|
@@ -203,7 +263,20 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
203
263
|
lines.push(this.ruleLine(innerWidth));
|
|
204
264
|
|
|
205
265
|
// Filter bar
|
|
206
|
-
if (this.
|
|
266
|
+
if (this.customMode) {
|
|
267
|
+
lines.push(
|
|
268
|
+
this.frameLine(
|
|
269
|
+
` ${this.fg("accent", "Model:")} ${this.custom}${this.fg("accent", "█")}`,
|
|
270
|
+
innerWidth,
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
lines.push(
|
|
274
|
+
this.frameLine(
|
|
275
|
+
` ${this.fg("dim", "e.g. omniroute/fal/fal-ai/flux-2-pro")}`,
|
|
276
|
+
innerWidth,
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
} else if (this.filterMode) {
|
|
207
280
|
lines.push(
|
|
208
281
|
this.frameLine(
|
|
209
282
|
` ${this.fg("accent", "Filter:")} ${this.filter}${this.fg("accent", "█")}`,
|
|
@@ -220,7 +293,7 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
220
293
|
} else {
|
|
221
294
|
lines.push(
|
|
222
295
|
this.frameLine(
|
|
223
|
-
` ${this.fg("dim", `${this.models.length} models ·
|
|
296
|
+
` ${this.fg("dim", `${this.models.length} models · / filter · c custom`)}`,
|
|
224
297
|
innerWidth,
|
|
225
298
|
),
|
|
226
299
|
);
|
|
@@ -233,7 +306,9 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
233
306
|
const start = Math.max(0, this.selectedIndex - Math.floor(maxVisible / 2));
|
|
234
307
|
const end = Math.min(this.filtered.length, start + maxVisible);
|
|
235
308
|
|
|
236
|
-
if (this.
|
|
309
|
+
if (this.customMode) {
|
|
310
|
+
// The list is noise while typing a model reference.
|
|
311
|
+
} else if (this.filtered.length === 0) {
|
|
237
312
|
const empty =
|
|
238
313
|
this.models.length === 0
|
|
239
314
|
? this.kind === "generate"
|
|
@@ -255,7 +330,7 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
255
330
|
}
|
|
256
331
|
}
|
|
257
332
|
|
|
258
|
-
if (this.filtered.length > maxVisible) {
|
|
333
|
+
if (!this.customMode && this.filtered.length > maxVisible) {
|
|
259
334
|
const pct = Math.round(((this.selectedIndex + 1) / this.filtered.length) * 100);
|
|
260
335
|
lines.push(
|
|
261
336
|
this.frameLine(
|
|
@@ -277,7 +352,12 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
277
352
|
lines.push(this.ruleLine(innerWidth));
|
|
278
353
|
lines.push(
|
|
279
354
|
this.frameLine(
|
|
280
|
-
this.fg(
|
|
355
|
+
this.fg(
|
|
356
|
+
"dim",
|
|
357
|
+
this.customMode
|
|
358
|
+
? "Enter save · Esc back to list"
|
|
359
|
+
: "↑↓ navigate · / filter · c custom · Enter select · Esc cancel",
|
|
360
|
+
),
|
|
281
361
|
innerWidth,
|
|
282
362
|
),
|
|
283
363
|
);
|
|
@@ -165,19 +165,20 @@ async function pickModel(
|
|
|
165
165
|
): Promise<void> {
|
|
166
166
|
const models = await collectModels(ctx, kind);
|
|
167
167
|
|
|
168
|
+
if (!ctx.hasUI) {
|
|
169
|
+
ctx.ui.notify("Model selection requires an interactive UI.", "warning");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// An empty catalog is NOT a dead end: the overlay's custom entry (`c`) is
|
|
174
|
+
// precisely the escape hatch for a provider whose models we cannot detect.
|
|
168
175
|
if (models.length === 0) {
|
|
169
176
|
ctx.ui.notify(
|
|
170
177
|
kind === "generate"
|
|
171
|
-
? "No image models
|
|
172
|
-
: "No vision-capable models
|
|
178
|
+
? "No image models detected — press c to enter one manually."
|
|
179
|
+
: "No vision-capable models detected — press c to enter one manually.",
|
|
173
180
|
"warning",
|
|
174
181
|
);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (!ctx.hasUI) {
|
|
179
|
-
ctx.ui.notify("Model selection requires an interactive UI.", "warning");
|
|
180
|
-
return;
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
const current =
|