pi-lemonade-link 1.0.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 +598 -0
- package/docs/README.md +22 -0
- package/docs/anthropic.md +11 -0
- package/docs/lemonade.md +2537 -0
- package/docs/llamacpp.md +442 -0
- package/docs/mcp.md +199 -0
- package/docs/ollama.md +21 -0
- package/docs/openai.md +1192 -0
- package/index.ts +3245 -0
- package/lemonade.example.json +85 -0
- package/package.json +24 -0
package/index.ts
ADDED
|
@@ -0,0 +1,3245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-lemonade-link — unified pi extension for a self-hosted Lemonade server
|
|
3
|
+
*
|
|
4
|
+
* One extension, four capabilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. Dynamic chat-model discovery — queries the server's model catalog and
|
|
7
|
+
* health endpoints at every pi startup, registers one
|
|
8
|
+
* lemonade-<instance-name> provider per configured instance
|
|
9
|
+
* with capability metadata (reasoning / vision / context window) and
|
|
10
|
+
* live loaded-state annotations. Chat-capable models only, by default.
|
|
11
|
+
* 2. Multimodal agent tools — transcribe_audio, generate_image, edit_image,
|
|
12
|
+
* vary_image, upscale_image, text_to_speech, generate_audio, and
|
|
13
|
+
* generate_3d_model, all backed by lemonade's OpenAI-compatible and
|
|
14
|
+
* Lemonade-specific endpoints. Unloaded models auto-load on demand;
|
|
15
|
+
* container audio formats are converted to wav via ffmpeg first.
|
|
16
|
+
* 3. /lemonade-setup — a navigable TUI menu for live status, endpoint
|
|
17
|
+
* configuration, server discovery, model management (load, unload,
|
|
18
|
+
* pull with live progress + cancel, delete with typed-phrase
|
|
19
|
+
* confirmation, change-ctx, Hugging Face search & install, filter,
|
|
20
|
+
* refresh).
|
|
21
|
+
* 4. A lemonade status bar — while a lemonade model is active, an extra
|
|
22
|
+
* below-editor row appears (never touching the footer): instance name,
|
|
23
|
+
* tok/s and prefix-cache hit % derived locally from the session's own
|
|
24
|
+
* messages, plus polled busy/queue, CPU/GPU/NPU and VRAM from the active
|
|
25
|
+
* instance.
|
|
26
|
+
* Switching to a non-lemonade model removes the row.
|
|
27
|
+
* 5. Pulls use lemonade's server-owned download jobs (stream + subscribe:
|
|
28
|
+
* false + GET /v1/downloads) with a live progress view, Esc to cancel —
|
|
29
|
+
* no blocking 30-minute waits. Blocking pull is kept as a fallback.
|
|
30
|
+
*
|
|
31
|
+
* Nothing is hardcoded: every endpoint lives in ~/.pi/agent/lemonade.json,
|
|
32
|
+
* editable at runtime via /lemonade-setup (changes re-register the provider
|
|
33
|
+
* immediately; no restart or /reload needed). On first run a missing config
|
|
34
|
+
* is auto-created as a minimal blank (zero servers) — an info notice points
|
|
35
|
+
* at /lemonade-setup, which opens a first-run wizard (discover or manual
|
|
36
|
+
* entry) instead of blocking. A config that exists but is unparseable or
|
|
37
|
+
* invalid still fails loudly and registers nothing. JSONC-style comments
|
|
38
|
+
* are allowed in the config file; note that /lemonade-setup rewrites strip
|
|
39
|
+
* them.
|
|
40
|
+
*
|
|
41
|
+
* Endpoints confirmed against lemonade 11.7.0 (see README for full list).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import * as fs from "node:fs";
|
|
45
|
+
import * as os from "node:os";
|
|
46
|
+
import * as path from "node:path";
|
|
47
|
+
import dgram from "node:dgram";
|
|
48
|
+
import { spawn } from "node:child_process";
|
|
49
|
+
import { Type } from "typebox";
|
|
50
|
+
import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
51
|
+
import { Container, Key, matchesKey, SelectList, Text, truncateToWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
52
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
53
|
+
|
|
54
|
+
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
interface LemonadeConfig {
|
|
57
|
+
/** Resolved per-instance on views produced by listInstances()/instanceView()
|
|
58
|
+
* (from servers[].baseUrl). Empty on the raw config — the only instance
|
|
59
|
+
* store is servers[], and servers[0] is the default instance. */
|
|
60
|
+
baseUrl: string;
|
|
61
|
+
apiKey: string; // shared fallback; a server entry without apiKey inherits it
|
|
62
|
+
chatPath: string; // OpenAI-compat chat completions base
|
|
63
|
+
modelsPath: string;
|
|
64
|
+
healthPath: string;
|
|
65
|
+
loadPath: string;
|
|
66
|
+
unloadPath: string;
|
|
67
|
+
pullPath: string;
|
|
68
|
+
deletePath: string;
|
|
69
|
+
downloadsPath: string;
|
|
70
|
+
downloadsControlPath: string;
|
|
71
|
+
registrySearchPath: string;
|
|
72
|
+
pullVariantsPath: string;
|
|
73
|
+
transcriptionPath: string;
|
|
74
|
+
imageGenerationPath: string;
|
|
75
|
+
imageEditPath: string;
|
|
76
|
+
imageVariationPath: string;
|
|
77
|
+
imageUpscalePath: string;
|
|
78
|
+
speechPath: string;
|
|
79
|
+
audioGenerationPath: string;
|
|
80
|
+
mesh3dPath: string;
|
|
81
|
+
classifyPath: string;
|
|
82
|
+
beaconPort: number;
|
|
83
|
+
chatOnly: boolean;
|
|
84
|
+
defaultTranscriptionModel: string;
|
|
85
|
+
defaultImageModel: string; // "" = auto-pick first image-labeled catalog model
|
|
86
|
+
defaultUpscaleModel: string;
|
|
87
|
+
defaultClassifierModel: string; // "" = auto-pick first classification model
|
|
88
|
+
outputDir: string; // "" = process cwd
|
|
89
|
+
statusPollMs: number; // status-panel live-refresh interval while viewing
|
|
90
|
+
statusBar: boolean; // belowEditor bar shown while a lemonade model is active
|
|
91
|
+
barPollMs: number; // bar server-poll cadence; 0 = local metrics only (tok/s, cache, instance)
|
|
92
|
+
discoveryTimeoutMs: number;
|
|
93
|
+
beaconTimeoutMs: number;
|
|
94
|
+
loadTimeoutMs: number;
|
|
95
|
+
pullTimeoutMs: number; // blocking-pull fallback cap only
|
|
96
|
+
transcriptionTimeoutMs: number;
|
|
97
|
+
generationTimeoutMs: number; // images, speech, audio gen, 3D
|
|
98
|
+
/** ALL lemonade instances — the only instance store. REQUIRED (at least
|
|
99
|
+
* one entry); the FIRST entry is the default instance (the one tools
|
|
100
|
+
* target when the `server` argument is omitted). There is no separate
|
|
101
|
+
* default: servers[0] IS the default, and reordering the array changes
|
|
102
|
+
* which instance that is. */
|
|
103
|
+
servers: ServerEntry[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ServerEntry {
|
|
107
|
+
name: string; // lowercase alnum + hyphens; "default" and duplicate names are reserved
|
|
108
|
+
baseUrl: string;
|
|
109
|
+
apiKey?: string; // falls back to the top-level (shared) apiKey when omitted
|
|
110
|
+
description?: string; // free-form context for the user's own reminder's sake
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const CONFIG_PATH = path.join(getAgentDir(), "lemonade.json");
|
|
114
|
+
|
|
115
|
+
const DEFAULT_CONFIG: LemonadeConfig = {
|
|
116
|
+
baseUrl: "", // views resolve this from servers[]; empty on the raw config
|
|
117
|
+
apiKey: "lemonade",
|
|
118
|
+
chatPath: "/api/v1",
|
|
119
|
+
modelsPath: "/v1/models",
|
|
120
|
+
healthPath: "/v1/health",
|
|
121
|
+
loadPath: "/v1/load",
|
|
122
|
+
unloadPath: "/v1/unload",
|
|
123
|
+
pullPath: "/v1/pull",
|
|
124
|
+
deletePath: "/v1/delete",
|
|
125
|
+
downloadsPath: "/v1/downloads",
|
|
126
|
+
downloadsControlPath: "/v1/downloads/control",
|
|
127
|
+
registrySearchPath: "/v1/registry/search",
|
|
128
|
+
pullVariantsPath: "/v1/pull/variants",
|
|
129
|
+
transcriptionPath: "/v1/audio/transcriptions",
|
|
130
|
+
imageGenerationPath: "/v1/images/generations",
|
|
131
|
+
imageEditPath: "/v1/images/edits",
|
|
132
|
+
imageVariationPath: "/v1/images/variations",
|
|
133
|
+
imageUpscalePath: "/v1/images/upscale",
|
|
134
|
+
speechPath: "/v1/audio/speech",
|
|
135
|
+
audioGenerationPath: "/v1/audio/generations",
|
|
136
|
+
mesh3dPath: "/v1/3d/generations",
|
|
137
|
+
classifyPath: "/v1/classify",
|
|
138
|
+
beaconPort: 13305,
|
|
139
|
+
chatOnly: true,
|
|
140
|
+
defaultTranscriptionModel: "Whisper-Large-v3",
|
|
141
|
+
defaultImageModel: "",
|
|
142
|
+
defaultUpscaleModel: "RealESRGAN-x4plus",
|
|
143
|
+
defaultClassifierModel: "",
|
|
144
|
+
outputDir: "",
|
|
145
|
+
statusPollMs: 2000,
|
|
146
|
+
statusBar: true,
|
|
147
|
+
barPollMs: 5000,
|
|
148
|
+
discoveryTimeoutMs: 5000,
|
|
149
|
+
beaconTimeoutMs: 3000,
|
|
150
|
+
loadTimeoutMs: 300_000,
|
|
151
|
+
pullTimeoutMs: 30 * 60 * 1000,
|
|
152
|
+
transcriptionTimeoutMs: 300_000,
|
|
153
|
+
generationTimeoutMs: 600_000,
|
|
154
|
+
servers: [],
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Strip JSONC comments — // line comments and block comments — so the
|
|
158
|
+
* config file can be annotated by hand. String-aware: // inside quoted
|
|
159
|
+
* values (URLs like http://...) is preserved. */
|
|
160
|
+
function stripJsonComments(text: string): string {
|
|
161
|
+
let out = "";
|
|
162
|
+
let inString = false;
|
|
163
|
+
let i = 0;
|
|
164
|
+
while (i < text.length) {
|
|
165
|
+
const c = text[i];
|
|
166
|
+
if (inString) {
|
|
167
|
+
out += c;
|
|
168
|
+
if (c === "\\" && i + 1 < text.length) {
|
|
169
|
+
out += text[i + 1];
|
|
170
|
+
i += 2;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (c === '"') inString = false;
|
|
174
|
+
i++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (c === '"') {
|
|
178
|
+
inString = true;
|
|
179
|
+
out += c;
|
|
180
|
+
i++;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
184
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
188
|
+
i += 2;
|
|
189
|
+
while (i + 1 < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
190
|
+
i += 2;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
out += c;
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Load the required config file. Missing or unparseable config is a hard
|
|
200
|
+
* failure — never a silent fallback to defaults, which would point at a
|
|
201
|
+
* server that may not be yours. The loud failure lives in piLemonadeLink()
|
|
202
|
+
* at the bottom of this file; this function just throws with a reason. */
|
|
203
|
+
/** Create a minimal, commented starter config if none exists yet.
|
|
204
|
+
* Returns true when the file was created (first run). A blank config has
|
|
205
|
+
* zero servers, which is legal: the extension registers nothing, and
|
|
206
|
+
* /lemonade-setup opens a first-run wizard to discover or add a box. */
|
|
207
|
+
function ensureConfigFile(p: string = CONFIG_PATH): boolean {
|
|
208
|
+
if (fs.existsSync(p)) return false;
|
|
209
|
+
const starter = `{
|
|
210
|
+
// Your lemonade server instances — this file was created automatically on
|
|
211
|
+
// first run. Nothing is registered until you add one. Easiest path:
|
|
212
|
+
// run /lemonade-setup (it offers discovery and guided entry), or edit this
|
|
213
|
+
// file and restart pi (or /reload).
|
|
214
|
+
// Each entry: { "name": "my-box", "baseUrl": "http://host:13305" }
|
|
215
|
+
// The FIRST entry is the default instance tools target when their 'server'
|
|
216
|
+
// argument is omitted. Every other setting has a built-in default — see
|
|
217
|
+
// lemonade.example.json beside the extension for the fully-commented
|
|
218
|
+
// reference.
|
|
219
|
+
"servers": []
|
|
220
|
+
}
|
|
221
|
+
`;
|
|
222
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
223
|
+
fs.writeFileSync(p, starter);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function loadConfig(p: string = CONFIG_PATH): LemonadeConfig {
|
|
228
|
+
if (!fs.existsSync(p)) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`${p} does not exist. Copy the fully-commented example from the extension folder ` +
|
|
231
|
+
`(lemonade.example.json) to your pi agent dir, edit "baseUrl" for your server, and restart pi.`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const raw = JSON.parse(stripJsonComments(fs.readFileSync(p, "utf8")));
|
|
235
|
+
if (typeof raw !== "object" || !raw || Array.isArray(raw)) {
|
|
236
|
+
throw new Error(`${p} must contain a JSON object.`);
|
|
237
|
+
}
|
|
238
|
+
// servers[] is the ONLY instance store — validate it hard, loudly, up front.
|
|
239
|
+
// It MAY be empty (a fresh install bootstraps with zero instances; the
|
|
240
|
+
// first /lemonade-setup run offers a wizard to add one).
|
|
241
|
+
if (!Array.isArray(raw.servers)) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`${p} must define a "servers" array (it may be empty on a fresh install). Minimal example: ` +
|
|
244
|
+
`{ "servers": [{ "name": "main", "baseUrl": "http://your-lemonade-server:13305" }] }`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const seen = new Set<string>();
|
|
248
|
+
for (const [i, s] of (raw.servers as Array<Record<string, unknown>>).entries()) {
|
|
249
|
+
if (!s || typeof s !== "object") throw new Error(`${p}: servers[${i}] must be an object.`);
|
|
250
|
+
if (typeof s.name !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.name) || s.name === "default") {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`${p}: servers[${i}].name must be lowercase letters/numbers/hyphens ("default" is reserved).`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (seen.has(s.name)) throw new Error(`${p}: duplicate instance name "${s.name}".`);
|
|
256
|
+
seen.add(s.name);
|
|
257
|
+
if (typeof s.baseUrl !== "string" || !/^https?:\/\//.test(s.baseUrl)) {
|
|
258
|
+
throw new Error(`${p}: servers[${i}].baseUrl must be an http(s) URL.`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { ...DEFAULT_CONFIG, ...raw };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function saveConfig(config: LemonadeConfig): void {
|
|
265
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ─── Multi-instance support ─────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/** A resolved instance: its name plus a config *view* — same shared settings,
|
|
271
|
+
* but baseUrl/apiKey swapped for that instance. Because every existing helper
|
|
272
|
+
* (fetchCatalog, tools, TUI actions, discovery) takes a LemonadeConfig, an
|
|
273
|
+
* instance view flows through all of them unchanged. This is the one place
|
|
274
|
+
* instance semantics are defined; everything else inherits it. servers[0]
|
|
275
|
+
* is the default instance — position, not a flag, decides defaultness. */
|
|
276
|
+
export interface InstanceView {
|
|
277
|
+
name: string;
|
|
278
|
+
description: string;
|
|
279
|
+
isDefault: boolean;
|
|
280
|
+
config: LemonadeConfig;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function listInstances(config: LemonadeConfig): InstanceView[] {
|
|
284
|
+
return config.servers.map((s, i) => ({
|
|
285
|
+
name: s.name,
|
|
286
|
+
description: s.description ?? "",
|
|
287
|
+
isDefault: i === 0,
|
|
288
|
+
config: { ...config, baseUrl: s.baseUrl, apiKey: s.apiKey || config.apiKey },
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Resolve a config view for the named instance. Returns the default view
|
|
293
|
+
* (servers[0]) when name is omitted; returns an error string for unknown
|
|
294
|
+
* names, so callers surface actionable text instead of dialing a wrong box. */
|
|
295
|
+
function instanceView(config: LemonadeConfig, name?: string): LemonadeConfig | string {
|
|
296
|
+
// "default" stays a reserved alias for servers[0], so old muscle memory
|
|
297
|
+
// and scripts keep working.
|
|
298
|
+
const target = !name || name === "default" ? config.servers[0] : config.servers.find((s) => s.name === name);
|
|
299
|
+
if (!target) {
|
|
300
|
+
return `Unknown lemonade instance "${name}". Available instances: ${config.servers.map((s) => s.name).join(", ")}.`;
|
|
301
|
+
}
|
|
302
|
+
return { ...config, baseUrl: target.baseUrl, apiKey: target.apiKey || config.apiKey };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** pi provider id for an instance — uniformly `lemonade-<name>` for EVERY
|
|
306
|
+
* instance, without prejudice. Model addresses follow: lemonade-main/
|
|
307
|
+
* <model>. Renaming an instance changes its id (and re-registers it); old
|
|
308
|
+
* references (pinned sessions, --model scripts) must follow the rename. */
|
|
309
|
+
function instanceProviderId(view: InstanceView): string {
|
|
310
|
+
return `lemonade-${view.name}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── Lemonade API types & helpers ───────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
interface LemonadeModel {
|
|
316
|
+
id: string;
|
|
317
|
+
labels?: string[];
|
|
318
|
+
recipe?: string;
|
|
319
|
+
size?: number;
|
|
320
|
+
downloaded?: boolean;
|
|
321
|
+
max_context_window?: number;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface LoadedModelInfo {
|
|
325
|
+
model_name: string;
|
|
326
|
+
type: string;
|
|
327
|
+
status: string;
|
|
328
|
+
device?: string;
|
|
329
|
+
pinned?: boolean;
|
|
330
|
+
max_context_window?: number;
|
|
331
|
+
checkpoint?: string;
|
|
332
|
+
recipe?: string;
|
|
333
|
+
is_busy?: boolean;
|
|
334
|
+
is_streaming?: boolean;
|
|
335
|
+
backend_health?: string;
|
|
336
|
+
backend_alive?: boolean;
|
|
337
|
+
pid?: number;
|
|
338
|
+
last_use?: number;
|
|
339
|
+
slot_pool?: string;
|
|
340
|
+
residency_class?: string;
|
|
341
|
+
watchdog_reset?: boolean;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
interface LemonadeHealth {
|
|
345
|
+
status?: string;
|
|
346
|
+
version?: string;
|
|
347
|
+
model_loaded?: string | null;
|
|
348
|
+
all_models_loaded?: LoadedModelInfo[];
|
|
349
|
+
websocket_port?: number;
|
|
350
|
+
max_models?: Record<string, number>;
|
|
351
|
+
pinned_models?: Record<string, number>;
|
|
352
|
+
pinned_helper_models?: Record<string, number>;
|
|
353
|
+
telemetry?: { enabled?: boolean; captures?: string[] };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
interface SystemStats {
|
|
357
|
+
cpu_percent?: number | null;
|
|
358
|
+
memory_gb?: number | null;
|
|
359
|
+
gpu_percent?: number | null;
|
|
360
|
+
vram_gb?: number | null;
|
|
361
|
+
npu_percent?: number | null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
interface PerfStats {
|
|
365
|
+
time_to_first_token?: number;
|
|
366
|
+
tokens_per_second?: number;
|
|
367
|
+
input_tokens?: number;
|
|
368
|
+
output_tokens?: number;
|
|
369
|
+
prompt_tokens?: number | null;
|
|
370
|
+
cache_tokens?: number | null;
|
|
371
|
+
request_count_total?: number;
|
|
372
|
+
input_tokens_total?: number;
|
|
373
|
+
output_tokens_total?: number;
|
|
374
|
+
prompt_tokens_total?: number;
|
|
375
|
+
cache_tokens_total?: number;
|
|
376
|
+
routing_decisions_total?: number;
|
|
377
|
+
routing_switches_total?: number;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** GPU device entry — shared shape (amd_gpu carries virtual_mem_gb, nvidia_gpu carries vram_gb). */
|
|
381
|
+
interface GpuInfo {
|
|
382
|
+
name?: string;
|
|
383
|
+
family?: string;
|
|
384
|
+
integrated?: boolean;
|
|
385
|
+
virtual_mem_gb?: number;
|
|
386
|
+
vram_gb?: number;
|
|
387
|
+
available?: boolean;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Hardware inventory from GET /v1/system-info (fields are platform-dependent). */
|
|
391
|
+
interface SystemInfo {
|
|
392
|
+
"OS Version"?: string;
|
|
393
|
+
Processor?: string;
|
|
394
|
+
"Physical Memory"?: string;
|
|
395
|
+
"Windows Power Setting"?: string;
|
|
396
|
+
model_storage?: { path?: string; used_bytes?: number; total_bytes?: number; free_bytes?: number };
|
|
397
|
+
devices?: {
|
|
398
|
+
cpu?: { name?: string; cores?: number; threads?: number; available?: boolean };
|
|
399
|
+
amd_gpu?: GpuInfo[];
|
|
400
|
+
nvidia_gpu?: GpuInfo[];
|
|
401
|
+
amd_npu?: { name?: string; family?: string; power_mode?: string; tops_max_int?: number; utilization?: number; available?: boolean };
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
interface DiscoveredServer {
|
|
406
|
+
hostname: string;
|
|
407
|
+
baseUrl: string;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Server-owned download job snapshot (GET /v1/downloads). */
|
|
411
|
+
interface DownloadJob {
|
|
412
|
+
id: string;
|
|
413
|
+
model_name?: string;
|
|
414
|
+
status: string; // downloading | paused | cancelled | completed | error
|
|
415
|
+
running?: boolean;
|
|
416
|
+
file?: string;
|
|
417
|
+
file_index?: number;
|
|
418
|
+
total_files?: number;
|
|
419
|
+
bytes_downloaded?: number;
|
|
420
|
+
bytes_total?: number;
|
|
421
|
+
percent?: number;
|
|
422
|
+
cumulative_bytes_downloaded?: number;
|
|
423
|
+
total_download_size?: number;
|
|
424
|
+
complete?: boolean;
|
|
425
|
+
error?: string;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
interface RegistryResult {
|
|
429
|
+
repository_id: string;
|
|
430
|
+
display_name?: string;
|
|
431
|
+
description?: string;
|
|
432
|
+
downloads?: number;
|
|
433
|
+
likes?: number;
|
|
434
|
+
tags?: string[];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
interface PullVariants {
|
|
438
|
+
checkpoint: string;
|
|
439
|
+
recipe?: string;
|
|
440
|
+
suggested_name?: string;
|
|
441
|
+
suggested_labels?: string[];
|
|
442
|
+
mmproj_files?: string[];
|
|
443
|
+
variants: Array<{ name: string; primary_file: string; files: string[]; sharded: boolean; size_bytes: number }>;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function url(config: LemonadeConfig, p: string): string {
|
|
447
|
+
return `${config.baseUrl.replace(/\/+$/, "")}${p}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function authHeaders(config: LemonadeConfig): Record<string, string> {
|
|
451
|
+
return config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function postJson(fullUrl: string, config: LemonadeConfig, body: unknown, timeoutMs: number): Promise<Response> {
|
|
455
|
+
const res = await fetch(fullUrl, {
|
|
456
|
+
method: "POST",
|
|
457
|
+
headers: { ...authHeaders(config), "Content-Type": "application/json" },
|
|
458
|
+
body: JSON.stringify(body),
|
|
459
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
460
|
+
});
|
|
461
|
+
if (!res.ok) {
|
|
462
|
+
throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
463
|
+
}
|
|
464
|
+
return res;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function fetchCatalog(config: LemonadeConfig, showAll = false): Promise<LemonadeModel[]> {
|
|
468
|
+
const res = await fetch(url(config, `${config.modelsPath}${showAll ? "?show_all=true" : ""}`), {
|
|
469
|
+
headers: authHeaders(config),
|
|
470
|
+
signal: AbortSignal.timeout(config.discoveryTimeoutMs),
|
|
471
|
+
});
|
|
472
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
473
|
+
const payload = (await res.json()) as { data?: LemonadeModel[] };
|
|
474
|
+
return payload.data ?? [];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function fetchJsonOrNull<T>(config: LemonadeConfig, p: string): Promise<T | null> {
|
|
478
|
+
try {
|
|
479
|
+
const res = await fetch(url(config, p), {
|
|
480
|
+
headers: authHeaders(config),
|
|
481
|
+
signal: AbortSignal.timeout(config.discoveryTimeoutMs),
|
|
482
|
+
});
|
|
483
|
+
return res.ok ? ((await res.json()) as T) : null;
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Fetch a plain-text endpoint (e.g. Prometheus /metrics); null when unreachable. */
|
|
490
|
+
async function fetchTextOrNull(config: LemonadeConfig, p: string): Promise<string | null> {
|
|
491
|
+
try {
|
|
492
|
+
const res = await fetch(url(config, p), {
|
|
493
|
+
headers: authHeaders(config),
|
|
494
|
+
signal: AbortSignal.timeout(config.discoveryTimeoutMs),
|
|
495
|
+
});
|
|
496
|
+
return res.ok ? await res.text() : null;
|
|
497
|
+
} catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Parse Prometheus text exposition into a name → value map (last sample wins). */
|
|
503
|
+
function parsePrometheus(body: string): Map<string, number> {
|
|
504
|
+
const values = new Map<string, number>();
|
|
505
|
+
for (const raw of body.split("\n")) {
|
|
506
|
+
const line = raw.trim();
|
|
507
|
+
if (!line || line.startsWith("#")) continue;
|
|
508
|
+
const m = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{[^}]*\})?\s+(-?[\d.]+(?:[eE][+-]?\d+)?)/);
|
|
509
|
+
if (m) values.set(m[1], parseFloat(m[2]));
|
|
510
|
+
}
|
|
511
|
+
return values;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function fetchHealth(config: LemonadeConfig): Promise<LemonadeHealth | null> {
|
|
515
|
+
return fetchJsonOrNull<LemonadeHealth>(config, config.healthPath);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function loadModel(config: LemonadeConfig, id: string, ctxSize?: number): Promise<string> {
|
|
519
|
+
const body: Record<string, unknown> = { model_name: id };
|
|
520
|
+
if (ctxSize) {
|
|
521
|
+
body.ctx_size = ctxSize;
|
|
522
|
+
body.save_options = true;
|
|
523
|
+
}
|
|
524
|
+
await postJson(url(config, config.loadPath), config, body, config.loadTimeoutMs);
|
|
525
|
+
return ctxSize ? `Loaded ${id} with ctx_size ${ctxSize}.` : `Loaded ${id}.`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function unloadModel(config: LemonadeConfig, id: string): Promise<void> {
|
|
529
|
+
await postJson(url(config, config.unloadPath), config, { model_name: id }, config.discoveryTimeoutMs);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Start a server-owned download job; returns the initial snapshot. */
|
|
533
|
+
async function startPullJob(config: LemonadeConfig, id: string, extra?: Record<string, unknown>): Promise<DownloadJob> {
|
|
534
|
+
const res = await postJson(url(config, config.pullPath), config, {
|
|
535
|
+
model_name: id,
|
|
536
|
+
stream: true,
|
|
537
|
+
subscribe: false,
|
|
538
|
+
...(extra ?? {}),
|
|
539
|
+
}, config.discoveryTimeoutMs);
|
|
540
|
+
return (await res.json()) as DownloadJob;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function listDownloads(config: LemonadeConfig): Promise<DownloadJob[] | null> {
|
|
544
|
+
return fetchJsonOrNull<DownloadJob[]>(config, config.downloadsPath);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function controlDownload(config: LemonadeConfig, jobId: string, action: "pause" | "cancel" | "remove"): Promise<void> {
|
|
548
|
+
await postJson(url(config, config.downloadsControlPath), config, { id: jobId, action }, config.discoveryTimeoutMs);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Blocking fallback for servers without download-job support. */
|
|
552
|
+
async function pullModelBlocking(config: LemonadeConfig, id: string): Promise<string> {
|
|
553
|
+
await postJson(url(config, config.pullPath), config, { model_name: id }, config.pullTimeoutMs);
|
|
554
|
+
return `Pulled ${id} (downloaded to disk).`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function deleteModel(config: LemonadeConfig, id: string): Promise<string> {
|
|
558
|
+
await postJson(url(config, config.deletePath), config, { model_name: id }, config.pullTimeoutMs);
|
|
559
|
+
return `Deleted ${id} from disk.`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Change ctx for a loaded model: unload → reload with new ctx_size + save_options. */
|
|
563
|
+
async function changeModelContext(config: LemonadeConfig, id: string, ctxSize: number): Promise<string> {
|
|
564
|
+
await unloadModel(config, id);
|
|
565
|
+
return loadModel(config, id, ctxSize);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Parse "32k", "1m", "262144" → number of tokens. */
|
|
569
|
+
function parseCtxSize(input: string): number | null {
|
|
570
|
+
const m = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)(k|m)?$/);
|
|
571
|
+
if (!m) return null;
|
|
572
|
+
const n = parseFloat(m[1]);
|
|
573
|
+
return m[2] === "k" ? Math.round(n * 1024) : m[2] === "m" ? Math.round(n * 1024 * 1024) : Math.round(n);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function formatBytes(bytes?: number): string {
|
|
577
|
+
if (!bytes || bytes <= 0) return "—";
|
|
578
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
579
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
580
|
+
return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ─── Server discovery (UDP beacon + HTTP fallback) ──────────────────────────
|
|
584
|
+
|
|
585
|
+
function normalizeBaseUrl(raw: string): string {
|
|
586
|
+
return raw.trim().replace(/\/+$/, "").replace(/\/api\/v1$/, "");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function discoverViaBeacon(config: LemonadeConfig, timeoutMs: number): Promise<DiscoveredServer[]> {
|
|
590
|
+
return new Promise((resolve) => {
|
|
591
|
+
const found = new Map<string, DiscoveredServer>();
|
|
592
|
+
let sock: ReturnType<typeof dgram.createSocket> | null = null;
|
|
593
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
594
|
+
|
|
595
|
+
const finish = () => {
|
|
596
|
+
if (timer) clearTimeout(timer);
|
|
597
|
+
if (sock) {
|
|
598
|
+
try { sock.close(); } catch { /* ignore */ }
|
|
599
|
+
sock = null;
|
|
600
|
+
}
|
|
601
|
+
resolve([...found.values()]);
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
sock = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
606
|
+
sock.on("error", finish);
|
|
607
|
+
sock.on("message", (msg: Buffer, rinfo: { address: string }) => {
|
|
608
|
+
try {
|
|
609
|
+
const beacon = JSON.parse(msg.toString());
|
|
610
|
+
if (beacon?.service !== "lemonade") return;
|
|
611
|
+
const base = normalizeBaseUrl(String(beacon.url ?? ""));
|
|
612
|
+
if (!base) return;
|
|
613
|
+
if (!found.has(base)) {
|
|
614
|
+
found.set(base, { hostname: String(beacon.hostname ?? rinfo.address), baseUrl: base });
|
|
615
|
+
}
|
|
616
|
+
} catch { /* not our beacon */ }
|
|
617
|
+
});
|
|
618
|
+
sock.bind(config.beaconPort);
|
|
619
|
+
} catch {
|
|
620
|
+
finish();
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
timer = setTimeout(finish, timeoutMs);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function discoverViaHttp(config: LemonadeConfig): Promise<DiscoveredServer[]> {
|
|
628
|
+
const candidateHosts = new Set(["localhost"]);
|
|
629
|
+
// Probe every configured instance's host, not just one: the raw config has
|
|
630
|
+
// no single baseUrl anymore — servers[] is the only instance store.
|
|
631
|
+
for (const s of config.servers) {
|
|
632
|
+
try {
|
|
633
|
+
candidateHosts.add(new URL(s.baseUrl).hostname);
|
|
634
|
+
} catch { /* skip malformed */ }
|
|
635
|
+
}
|
|
636
|
+
const ports = [13305, 8000, 1234, 9000, 8080];
|
|
637
|
+
|
|
638
|
+
const probes = [...candidateHosts].flatMap((host) =>
|
|
639
|
+
ports.map(async (port) => {
|
|
640
|
+
try {
|
|
641
|
+
const res = await fetch(`http://${host}:${port}${config.healthPath}`, {
|
|
642
|
+
signal: AbortSignal.timeout(1500),
|
|
643
|
+
});
|
|
644
|
+
if (!res.ok) return null;
|
|
645
|
+
const body = (await res.json()) as { status?: string; version?: string };
|
|
646
|
+
if (typeof body.status !== "string") return null;
|
|
647
|
+
return { hostname: host, baseUrl: `http://${host}:${port}` } as DiscoveredServer;
|
|
648
|
+
} catch {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
})
|
|
652
|
+
);
|
|
653
|
+
const results = await Promise.all(probes);
|
|
654
|
+
const seen = new Map<string, DiscoveredServer>();
|
|
655
|
+
for (const r of results) if (r && !seen.has(r.baseUrl)) seen.set(r.baseUrl, r);
|
|
656
|
+
return [...seen.values()];
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ─── Provider registration ──────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
function isChatModel(m: LemonadeModel): boolean {
|
|
662
|
+
return (
|
|
663
|
+
(m.labels ?? []).includes("chat") ||
|
|
664
|
+
m.recipe === "llamacpp" ||
|
|
665
|
+
// Omni collections bundle a chat LLM with server-side tools (image gen,
|
|
666
|
+
// TTS) and are driven through chat completions like any chat model.
|
|
667
|
+
m.recipe === "collection.omni"
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function toProviderModel(m: LemonadeModel, loaded: Set<string>, providerId = "lemonade") {
|
|
672
|
+
const labels = m.labels ?? [];
|
|
673
|
+
const contextWindow = m.max_context_window ?? 128000;
|
|
674
|
+
return {
|
|
675
|
+
id: m.id,
|
|
676
|
+
name: loaded.has(m.id)
|
|
677
|
+
? `${m.id} (${providerId}, loaded)`
|
|
678
|
+
: `${m.id} (${providerId}, on-demand)`,
|
|
679
|
+
reasoning: labels.includes("reasoning"),
|
|
680
|
+
input: labels.includes("vision") ? (["text", "image"] as ("text" | "image")[]) : (["text"] as ("text" | "image")[]),
|
|
681
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
682
|
+
contextWindow,
|
|
683
|
+
maxTokens: Math.min(contextWindow, 32768),
|
|
684
|
+
compat: {
|
|
685
|
+
supportsDeveloperRole: false,
|
|
686
|
+
supportsReasoningEffort: false,
|
|
687
|
+
maxTokensField: "max_tokens" as const,
|
|
688
|
+
},
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): Promise<number> {
|
|
693
|
+
const config = view.config;
|
|
694
|
+
const providerId = instanceProviderId(view);
|
|
695
|
+
let catalog: LemonadeModel[];
|
|
696
|
+
let loaded = new Set<string>();
|
|
697
|
+
try {
|
|
698
|
+
const [catalogRes, health] = await Promise.all([
|
|
699
|
+
fetchCatalog(config),
|
|
700
|
+
fetchHealth(config),
|
|
701
|
+
]);
|
|
702
|
+
catalog = catalogRes;
|
|
703
|
+
loaded = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
|
|
704
|
+
} catch {
|
|
705
|
+
// Unreachable => contribute nothing to /model. No placeholders: an
|
|
706
|
+
// unreachable model is not listable. Sessions pinned to a lemonade model
|
|
707
|
+
// will hard-fail model resolution until the box is reachable again —
|
|
708
|
+
// the accepted tradeoff of "fully automated, fully honest" listings.
|
|
709
|
+
catalog = [];
|
|
710
|
+
console.error(
|
|
711
|
+
`[pi-lemonade-link] instance "${view.name}" unreachable at ${config.baseUrl}; registering no models`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const advertised = config.chatOnly ? catalog.filter(isChatModel) : catalog;
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
pi.unregisterProvider(providerId);
|
|
719
|
+
} catch {
|
|
720
|
+
// not previously registered
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
pi.registerProvider(providerId, {
|
|
724
|
+
name: `Lemonade ${view.name} (self-hosted)`,
|
|
725
|
+
baseUrl: url(config, config.chatPath),
|
|
726
|
+
apiKey: config.apiKey || "lemonade",
|
|
727
|
+
authHeader: true,
|
|
728
|
+
api: "openai-completions",
|
|
729
|
+
models: advertised.map((m) => toProviderModel(m, loaded, providerId)),
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
return advertised.length;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Provider ids used by past versions of this extension. unregisterProvider
|
|
736
|
+
* only works within the running pi process, and ids changed over the
|
|
737
|
+
* extension's evolution, so a long-lived session (or /reload after an
|
|
738
|
+
* upgrade) can accumulate ghost providers registered by old code. Each
|
|
739
|
+
* registration pass sweeps them; anything not in this list and not a
|
|
740
|
+
* current instance id persists until the pi process restarts. */
|
|
741
|
+
const LEGACY_PROVIDER_IDS = ["lemonade", "lemonade-undefined"];
|
|
742
|
+
|
|
743
|
+
/** Register (or re-register) every configured instance. Each becomes its own
|
|
744
|
+
* pi provider under the uniform lemonade-<name> id. */
|
|
745
|
+
async function registerLemonadeProvider(pi: ExtensionAPI, config: LemonadeConfig): Promise<number> {
|
|
746
|
+
for (const staleId of LEGACY_PROVIDER_IDS) {
|
|
747
|
+
try {
|
|
748
|
+
pi.unregisterProvider(staleId);
|
|
749
|
+
} catch { /* never registered; fine */ }
|
|
750
|
+
}
|
|
751
|
+
let total = 0;
|
|
752
|
+
for (const view of listInstances(config)) {
|
|
753
|
+
total += await registerInstanceProvider(pi, view);
|
|
754
|
+
}
|
|
755
|
+
return total;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ─── Multimodal agent tools ──────────────────────────────────────────────────
|
|
759
|
+
|
|
760
|
+
/** Combine an operation timeout with the caller's cancel signal (if any),
|
|
761
|
+
* so long-running requests are both bounded and user-cancellable. */
|
|
762
|
+
function boundedSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
|
763
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
764
|
+
return signal ? AbortSignal.any([timeout, signal]) : timeout;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function readRequiredFile(resolved: string): Promise<Buffer> {
|
|
768
|
+
return fs.promises.readFile(resolved);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** whisper.cpp's backend ingests raw wav only; every other format (mp3, mp4,
|
|
772
|
+
* ogg, flac, ...) is converted to 16 kHz mono pcm_s16le wav via ffmpeg on
|
|
773
|
+
* pi's machine first (the official API documents wav as the only input). */
|
|
774
|
+
const WHISPER_NATIVE_EXT = new Set([".wav", ".wave"]);
|
|
775
|
+
|
|
776
|
+
async function prepareAudioForWhisper(config: LemonadeConfig, resolved: string): Promise<string> {
|
|
777
|
+
if (WHISPER_NATIVE_EXT.has(path.extname(resolved).toLowerCase())) return resolved;
|
|
778
|
+
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-whisper-"));
|
|
779
|
+
const converted = path.join(tmpDir, "converted.wav");
|
|
780
|
+
await runFfmpeg(
|
|
781
|
+
["-i", resolved, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", converted],
|
|
782
|
+
config.transcriptionTimeoutMs
|
|
783
|
+
);
|
|
784
|
+
return converted;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Transcribe one file through the server's Whisper endpoint — shared by the
|
|
788
|
+
* transcribe_audio tool and the setup menu's smoke test, so both stay in
|
|
789
|
+
* lockstep on conversion, model resolution and error wording. Throws Errors
|
|
790
|
+
* with user-facing text; tool callers convert them to toolError() results
|
|
791
|
+
* (rethrowing AbortError so user cancels stay cancels). */
|
|
792
|
+
async function transcribeViaServer(
|
|
793
|
+
config: LemonadeConfig,
|
|
794
|
+
filePath: string,
|
|
795
|
+
language?: string,
|
|
796
|
+
signal?: AbortSignal
|
|
797
|
+
): Promise<{ text: string; model: string; file: string }> {
|
|
798
|
+
const resolved = path.resolve(filePath);
|
|
799
|
+
let fileToSend: string;
|
|
800
|
+
let data: Buffer;
|
|
801
|
+
try {
|
|
802
|
+
fileToSend = await prepareAudioForWhisper(config, resolved);
|
|
803
|
+
data = await readRequiredFile(fileToSend);
|
|
804
|
+
} catch (error) {
|
|
805
|
+
throw new Error(`Could not prepare '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
|
|
806
|
+
}
|
|
807
|
+
try {
|
|
808
|
+
const model = config.defaultTranscriptionModel || (await pickModelByLabels(config, ["transcription", "whisper"]));
|
|
809
|
+
if (!model) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
"No transcription model found on the lemonade server. Set defaultTranscriptionModel in ~/.pi/agent/lemonade.json, or pull one first (e.g. Whisper-Large-v3)."
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
const form = new FormData();
|
|
815
|
+
form.append("file", new File([data], path.basename(fileToSend)));
|
|
816
|
+
form.append("model", model);
|
|
817
|
+
if (language) form.append("language", language);
|
|
818
|
+
const response = await toolFetch(config, config.transcriptionPath, {
|
|
819
|
+
method: "POST",
|
|
820
|
+
headers: authHeaders(config),
|
|
821
|
+
body: form,
|
|
822
|
+
signal: boundedSignal(config.transcriptionTimeoutMs, signal),
|
|
823
|
+
});
|
|
824
|
+
if (!response.ok) {
|
|
825
|
+
throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 400)}`);
|
|
826
|
+
}
|
|
827
|
+
const payload = (await response.json()) as { text?: string };
|
|
828
|
+
return { text: payload.text ?? "", model, file: resolved };
|
|
829
|
+
} finally {
|
|
830
|
+
// Converted intermediates are disposable — remove the temp dir best-effort.
|
|
831
|
+
if (fileToSend !== resolved) {
|
|
832
|
+
await fs.promises.rm(path.dirname(fileToSend), { recursive: true, force: true }).catch(() => {});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Find a sensible default model for a tool, from the live catalog.
|
|
838
|
+
*
|
|
839
|
+
* Returns: the picked model id, undefined if no catalog model matches, or
|
|
840
|
+
* null if the server is unreachable — callers must surface that distinctly,
|
|
841
|
+
* because "nothing matches" and "no route to the server" need different advice.
|
|
842
|
+
*/
|
|
843
|
+
async function pickModel(
|
|
844
|
+
config: LemonadeConfig,
|
|
845
|
+
matches: (m: LemonadeModel) => boolean
|
|
846
|
+
): Promise<string | null | undefined> {
|
|
847
|
+
try {
|
|
848
|
+
const hits = (await fetchCatalog(config)).filter(matches);
|
|
849
|
+
const downloaded = hits.find((m) => m.downloaded !== false) ?? hits[0];
|
|
850
|
+
return downloaded?.id;
|
|
851
|
+
} catch {
|
|
852
|
+
return null; // unreachable
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Auto-pick by label (e.g. ["image"], ["tts"]). */
|
|
857
|
+
function pickModelByLabels(
|
|
858
|
+
config: LemonadeConfig,
|
|
859
|
+
labelCandidates: string[]
|
|
860
|
+
): Promise<string | null | undefined> {
|
|
861
|
+
return pickModel(config, (m) => (m.labels ?? []).some((l) => labelCandidates.includes(l)));
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** Auto-pick by recipe (e.g. onnxruntime classifiers). */
|
|
865
|
+
function pickModelByRecipe(
|
|
866
|
+
config: LemonadeConfig,
|
|
867
|
+
recipe: string
|
|
868
|
+
): Promise<string | null | undefined> {
|
|
869
|
+
return pickModel(config, (m) => m.recipe === recipe);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function toolError(message: string): { content: Array<{ type: "text"; text: string }>; details: {} } {
|
|
873
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], details: {} };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function unreachableMessage(config: LemonadeConfig): string {
|
|
877
|
+
return (
|
|
878
|
+
`Lemonade server at ${config.baseUrl} is unreachable from this network — ` +
|
|
879
|
+
"there is probably no route back to the lemonade LAN. Reconnect to a network " +
|
|
880
|
+
"with access, or point the extension at a reachable instance: /lemonade-setup → " +
|
|
881
|
+
"Server settings → Manage instances (or Discover servers)."
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** fetch() for tool paths: converts raw network rejections (the cryptic
|
|
886
|
+
* "fetch failed") into a descriptive, actionable error. Abort/cancel passes
|
|
887
|
+
* through untouched. */
|
|
888
|
+
async function toolFetch(config: LemonadeConfig, p: string, init: RequestInit): Promise<Response> {
|
|
889
|
+
try {
|
|
890
|
+
return await fetch(url(config, p), init);
|
|
891
|
+
} catch (error) {
|
|
892
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
893
|
+
throw new Error(unreachableMessage(config));
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** POST JSON, save binary response body to a file. */
|
|
898
|
+
async function postJsonSaveBinary(
|
|
899
|
+
config: LemonadeConfig,
|
|
900
|
+
p: string,
|
|
901
|
+
body: Record<string, unknown>,
|
|
902
|
+
kind: string,
|
|
903
|
+
ext: string,
|
|
904
|
+
signal?: AbortSignal,
|
|
905
|
+
extra?: string
|
|
906
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
|
|
907
|
+
const res = await toolFetch(config, p, {
|
|
908
|
+
method: "POST",
|
|
909
|
+
headers: { ...authHeaders(config), "Content-Type": "application/json" },
|
|
910
|
+
body: JSON.stringify(body),
|
|
911
|
+
signal: boundedSignal(config.generationTimeoutMs, signal),
|
|
912
|
+
});
|
|
913
|
+
if (!res.ok) {
|
|
914
|
+
return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
|
|
915
|
+
}
|
|
916
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
917
|
+
const file = outputFilePath(config, kind, ext);
|
|
918
|
+
await fs.promises.writeFile(file, buf);
|
|
919
|
+
return {
|
|
920
|
+
content: [{
|
|
921
|
+
type: "text",
|
|
922
|
+
text: `Saved ${file} (${formatBytes(buf.length)}${extra ? `, ${extra}` : ""}).`,
|
|
923
|
+
}],
|
|
924
|
+
details: { file },
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Shared response handling for the image endpoints: an OpenAI-style JSON
|
|
929
|
+
* payload with b64_json data, or a raw binary body. Saves the PNG and
|
|
930
|
+
* returns the standard tool result. */
|
|
931
|
+
async function saveImageResponse(
|
|
932
|
+
config: LemonadeConfig,
|
|
933
|
+
res: Response,
|
|
934
|
+
kind: string,
|
|
935
|
+
extra?: string
|
|
936
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
|
|
937
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
938
|
+
const file = outputFilePath(config, kind, "png");
|
|
939
|
+
if (contentType.includes("application/json")) {
|
|
940
|
+
const payload = (await res.json()) as { data?: Array<{ b64_json?: string }> };
|
|
941
|
+
const b64 = payload.data?.[0]?.b64_json;
|
|
942
|
+
if (!b64) return toolError("Server response contained no image data.");
|
|
943
|
+
await fs.promises.writeFile(file, Buffer.from(b64, "base64"));
|
|
944
|
+
} else {
|
|
945
|
+
await fs.promises.writeFile(file, Buffer.from(await res.arrayBuffer()));
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
content: [{ type: "text", text: `Saved ${file}${extra ? ` (${extra})` : ""}.` }],
|
|
949
|
+
details: { file },
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** POST multipart, save the response's b64_json / raw binary to a PNG file. */
|
|
954
|
+
async function postFormSaveImage(
|
|
955
|
+
config: LemonadeConfig,
|
|
956
|
+
p: string,
|
|
957
|
+
form: FormData,
|
|
958
|
+
kind: string,
|
|
959
|
+
signal?: AbortSignal
|
|
960
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
|
|
961
|
+
const res = await toolFetch(config, p, {
|
|
962
|
+
method: "POST",
|
|
963
|
+
headers: authHeaders(config),
|
|
964
|
+
body: form,
|
|
965
|
+
signal: boundedSignal(config.generationTimeoutMs, signal),
|
|
966
|
+
});
|
|
967
|
+
if (!res.ok) {
|
|
968
|
+
return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
|
|
969
|
+
}
|
|
970
|
+
return saveImageResponse(config, res, kind);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** POST JSON, save the response's b64_json / raw binary to a PNG file. */
|
|
974
|
+
async function postJsonSaveImage(
|
|
975
|
+
config: LemonadeConfig,
|
|
976
|
+
p: string,
|
|
977
|
+
body: Record<string, unknown>,
|
|
978
|
+
kind: string,
|
|
979
|
+
signal?: AbortSignal,
|
|
980
|
+
extra?: string
|
|
981
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
|
|
982
|
+
const res = await toolFetch(config, p, {
|
|
983
|
+
method: "POST",
|
|
984
|
+
headers: { ...authHeaders(config), "Content-Type": "application/json" },
|
|
985
|
+
body: JSON.stringify(body),
|
|
986
|
+
signal: boundedSignal(config.generationTimeoutMs, signal),
|
|
987
|
+
});
|
|
988
|
+
if (!res.ok) {
|
|
989
|
+
return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
|
|
990
|
+
}
|
|
991
|
+
return saveImageResponse(config, res, kind, extra);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function outputDir(config: LemonadeConfig): string {
|
|
995
|
+
return config.outputDir ? path.resolve(config.outputDir) : process.cwd();
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function outputFilePath(config: LemonadeConfig, kind: string, ext: string): string {
|
|
999
|
+
// Millisecond precision: second-resolution stamps silently overwrite when
|
|
1000
|
+
// two outputs land in the same second (e.g. batch generation).
|
|
1001
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23);
|
|
1002
|
+
return path.join(outputDir(config), `lemonade-${kind}-${stamp}.${ext}`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function runFfmpeg(args: string[], timeoutMs: number): Promise<void> {
|
|
1006
|
+
return new Promise((resolve, reject) => {
|
|
1007
|
+
const child = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...args]);
|
|
1008
|
+
let stderr = "";
|
|
1009
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
1010
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
1011
|
+
child.on("error", (e) => { clearTimeout(timer); reject(e); });
|
|
1012
|
+
child.on("exit", (code) => {
|
|
1013
|
+
clearTimeout(timer);
|
|
1014
|
+
code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(0, 300)}`));
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Promise<void> {
|
|
1020
|
+
// Harness-level self-awareness: these lines are merged into the Guidelines
|
|
1021
|
+
// section of every chat's system prompt, so even a virgin chat knows where
|
|
1022
|
+
// its lemonade machinery lives without having to hunt for it. (pi dedupes
|
|
1023
|
+
// guideline strings, so attaching once is enough.)
|
|
1024
|
+
const instanceList = listInstances(gconfig)
|
|
1025
|
+
.map((v) => `${v.name}${v.isDefault ? " (default)" : ""} (${v.config.baseUrl})`)
|
|
1026
|
+
.join("; ");
|
|
1027
|
+
const AWARENESS = [
|
|
1028
|
+
"Self-hosted Lemonade server: base URL, endpoint paths, and defaults live in ~/.pi/agent/lemonade.json; full documentation (tools, endpoints, behavior reference, official API spec) is at ~/.pi/agent/extensions/pi-lemonade-link/README.md and docs/ beside it.",
|
|
1029
|
+
"For questions about the lemonade server (loaded models, catalog, health, host resources), query its HTTP API directly with bash + curl using the config; for interactive management, tell the user to run the /lemonade-setup command.",
|
|
1030
|
+
`Lemonade instances available to tools: ${
|
|
1031
|
+
instanceList ||
|
|
1032
|
+
"(none configured yet — add one via /lemonade-setup or ~/.pi/agent/lemonade.json)"
|
|
1033
|
+
}. Tools accept an optional server argument naming an instance; omit it for the default.`,
|
|
1034
|
+
];
|
|
1035
|
+
|
|
1036
|
+
const SERVER_PARAM_DESC =
|
|
1037
|
+
"Instance name for multi-server setups (omit for the default instance; see guidelines for the available names).";
|
|
1038
|
+
|
|
1039
|
+
// Enumerate the classifiers actually present on the configured instances, so
|
|
1040
|
+
// the tool's advertisement reflects reality each session. The label universe
|
|
1041
|
+
// of each classifier is baked into the model itself and is revealed at
|
|
1042
|
+
// runtime by the response's labels — the description can only hint via
|
|
1043
|
+
// model ids.
|
|
1044
|
+
let classifierHint = "";
|
|
1045
|
+
{
|
|
1046
|
+
const ids = new Set<string>();
|
|
1047
|
+
const pulled = new Set<string>();
|
|
1048
|
+
await Promise.all(
|
|
1049
|
+
listInstances(gconfig).map(async (v) => {
|
|
1050
|
+
try {
|
|
1051
|
+
const catalog = await fetchCatalog(v.config);
|
|
1052
|
+
for (const m of catalog.filter((m) => m.recipe === "onnxruntime")) {
|
|
1053
|
+
ids.add(m.id);
|
|
1054
|
+
if (m.downloaded !== false) pulled.add(m.id);
|
|
1055
|
+
}
|
|
1056
|
+
} catch {
|
|
1057
|
+
// instance unreachable at registration time — hint stays generic for it
|
|
1058
|
+
}
|
|
1059
|
+
})
|
|
1060
|
+
);
|
|
1061
|
+
if (ids.size) {
|
|
1062
|
+
classifierHint =
|
|
1063
|
+
`Classifiers on this server: ${[...ids].join(", ")}. ` +
|
|
1064
|
+
(pulled.size ? `Pulled and ready: ${[...pulled].join(", ")}.` : "None pulled yet — pull via /lemonade-setup.");
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// ── transcribe_audio ────────────────────────────────────────────────────
|
|
1069
|
+
pi.registerTool({
|
|
1070
|
+
name: "transcribe_audio",
|
|
1071
|
+
label: "Transcribe Audio",
|
|
1072
|
+
description:
|
|
1073
|
+
"Transcribe an audio or video file (wav, mp3, mp4, m4a, webm, flac, ...) to text " +
|
|
1074
|
+
"using a Whisper model on the self-hosted lemonade server. Any format other " +
|
|
1075
|
+
"than raw wav is converted to 16 kHz mono wav with ffmpeg automatically. Returns the transcript.",
|
|
1076
|
+
promptSnippet: "[lemonade] Transcribe audio or video files (wav, mp3, mp4, m4a, webm, ...) to text with Whisper",
|
|
1077
|
+
promptGuidelines: AWARENESS,
|
|
1078
|
+
parameters: Type.Object({
|
|
1079
|
+
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1080
|
+
file_path: Type.String({ description: "Path to the audio/video file to transcribe." }),
|
|
1081
|
+
language: Type.Optional(
|
|
1082
|
+
Type.String({ description: "Optional ISO-639-1 language hint (e.g. 'en')." })
|
|
1083
|
+
),
|
|
1084
|
+
}),
|
|
1085
|
+
async execute(_toolCallId, params, signal) {
|
|
1086
|
+
const instCfg = instanceView(gconfig, params.server);
|
|
1087
|
+
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1088
|
+
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1089
|
+
const resolved = path.resolve(params.file_path);
|
|
1090
|
+
try {
|
|
1091
|
+
const { text, model } = await transcribeViaServer(config, resolved, params.language, signal);
|
|
1092
|
+
return {
|
|
1093
|
+
content: [{ type: "text", text }],
|
|
1094
|
+
details: { file: resolved, model },
|
|
1095
|
+
};
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
// User cancels stay cancels; everything else becomes a readable error.
|
|
1098
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
1099
|
+
return toolError(error instanceof Error ? error.message : String(error));
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// ── generate_image ──────────────────────────────────────────────────────
|
|
1105
|
+
pi.registerTool({
|
|
1106
|
+
name: "generate_image",
|
|
1107
|
+
label: "Generate Image",
|
|
1108
|
+
description:
|
|
1109
|
+
"Generate an image from a text prompt using an image model on the lemonade server " +
|
|
1110
|
+
"(e.g. Flux, Z-Image, SD-Turbo). Saves the PNG to disk and returns its path. " +
|
|
1111
|
+
"If no model is specified, the first image model in the catalog is used. " +
|
|
1112
|
+
"Image generation can take a while on first use (model auto-loads).",
|
|
1113
|
+
promptSnippet: "[lemonade] Generate images from text prompts with the local Flux/SD models (saves PNG to disk)",
|
|
1114
|
+
parameters: Type.Object({
|
|
1115
|
+
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1116
|
+
prompt: Type.String({ description: "Text description of the image to generate." }),
|
|
1117
|
+
model: Type.Optional(Type.String({ description: "Image model id. Omit to auto-pick." })),
|
|
1118
|
+
size: Type.Optional(Type.String({ description: "Output size as WIDTHxHEIGHT, e.g. 512x512." })),
|
|
1119
|
+
steps: Type.Optional(Type.Number({ description: "Inference steps. Turbo models work well with 4." })),
|
|
1120
|
+
cfg_scale: Type.Optional(Type.Number({ description: "Classifier-free guidance scale. Turbo ~1.0, standard ~7.5." })),
|
|
1121
|
+
seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility." })),
|
|
1122
|
+
}),
|
|
1123
|
+
async execute(_toolCallId, params, signal) {
|
|
1124
|
+
const instCfg = instanceView(gconfig, params.server);
|
|
1125
|
+
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1126
|
+
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1127
|
+
const auto = await pickModelByLabels(config, ["image"]);
|
|
1128
|
+
if (auto === null) return toolError(unreachableMessage(config));
|
|
1129
|
+
const model = params.model || config.defaultImageModel || auto;
|
|
1130
|
+
if (!model) return toolError("No image model found on the lemonade server. Pull one first (e.g. Z-Image-Turbo).");
|
|
1131
|
+
const body: Record<string, unknown> = { model, prompt: params.prompt, response_format: "b64_json" };
|
|
1132
|
+
if (params.size) body.size = params.size;
|
|
1133
|
+
if (params.steps) body.steps = params.steps;
|
|
1134
|
+
if (params.cfg_scale) body.cfg_scale = params.cfg_scale;
|
|
1135
|
+
if (params.seed !== undefined) body.seed = params.seed;
|
|
1136
|
+
return postJsonSaveImage(config, config.imageGenerationPath, body, "image", signal, `model: ${model}`);
|
|
1137
|
+
},
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
// ── edit_image ──────────────────────────────────────────────────────────
|
|
1141
|
+
pi.registerTool({
|
|
1142
|
+
name: "edit_image",
|
|
1143
|
+
label: "Edit Image",
|
|
1144
|
+
description:
|
|
1145
|
+
"Edit an image with a text prompt (e.g. 'add a red barn, photorealistic') using an " +
|
|
1146
|
+
"edit-capable image model on the lemonade server. Saves the edited PNG and returns its path.",
|
|
1147
|
+
promptSnippet: "[lemonade] Edit local images with text prompts (add/remove/change things), saves result PNG",
|
|
1148
|
+
parameters: Type.Object({
|
|
1149
|
+
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1150
|
+
image_path: Type.String({ description: "Path to the source image (PNG)." }),
|
|
1151
|
+
prompt: Type.String({ description: "Description of the desired edit." }),
|
|
1152
|
+
model: Type.Optional(Type.String({ description: "Edit-capable model id (e.g. Flux-2-Klein-9B-GGUF). Omit to auto-pick." })),
|
|
1153
|
+
mask_path: Type.Optional(Type.String({ description: "Optional mask PNG: white = edit, black = preserve." })),
|
|
1154
|
+
size: Type.Optional(Type.String({ description: "Output size WIDTHxHEIGHT, e.g. 512x512." })),
|
|
1155
|
+
}),
|
|
1156
|
+
async execute(_toolCallId, params, signal) {
|
|
1157
|
+
const instCfg = instanceView(gconfig, params.server);
|
|
1158
|
+
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1159
|
+
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1160
|
+
const resolved = path.resolve(params.image_path);
|
|
1161
|
+
let buf: Buffer;
|
|
1162
|
+
try {
|
|
1163
|
+
buf = await readRequiredFile(resolved);
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
return toolError(`Could not read '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1166
|
+
}
|
|
1167
|
+
const auto = await pickModelByLabels(config, ["edit", "image"]);
|
|
1168
|
+
if (auto === null) return toolError(unreachableMessage(config));
|
|
1169
|
+
const model = params.model || config.defaultImageModel || auto;
|
|
1170
|
+
if (!model) return toolError("No edit-capable image model found on the lemonade server.");
|
|
1171
|
+
const form = new FormData();
|
|
1172
|
+
form.append("model", model);
|
|
1173
|
+
form.append("prompt", params.prompt);
|
|
1174
|
+
form.append("response_format", "b64_json");
|
|
1175
|
+
if (params.size) form.append("size", params.size);
|
|
1176
|
+
form.append("image", new File([buf], path.basename(resolved), { type: "image/png" }));
|
|
1177
|
+
if (params.mask_path) {
|
|
1178
|
+
try {
|
|
1179
|
+
const mask = await readRequiredFile(path.resolve(params.mask_path));
|
|
1180
|
+
form.append("mask", new File([mask], path.basename(params.mask_path!), { type: "image/png" }));
|
|
1181
|
+
} catch { /* mask is optional */ }
|
|
1182
|
+
}
|
|
1183
|
+
const result = await postFormSaveImage(config, config.imageEditPath, form, "image-edit", signal);
|
|
1184
|
+
return result;
|
|
1185
|
+
},
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// ── vary_image ──────────────────────────────────────────────────────────
|
|
1189
|
+
pi.registerTool({
|
|
1190
|
+
name: "vary_image",
|
|
1191
|
+
label: "Vary Image",
|
|
1192
|
+
description:
|
|
1193
|
+
"Generate a variation of an image using the lemonade server's image models. " +
|
|
1194
|
+
"Saves the variation PNG and returns its path.",
|
|
1195
|
+
promptSnippet: "[lemonade] Generate variations of local images",
|
|
1196
|
+
parameters: Type.Object({
|
|
1197
|
+
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1198
|
+
image_path: Type.String({ description: "Path to the source image (PNG)." }),
|
|
1199
|
+
model: Type.Optional(Type.String({ description: "Image model id. Omit to auto-pick." })),
|
|
1200
|
+
size: Type.Optional(Type.String({ description: "Output size WIDTHxHEIGHT." })),
|
|
1201
|
+
}),
|
|
1202
|
+
async execute(_toolCallId, params, signal) {
|
|
1203
|
+
const instCfg = instanceView(gconfig, params.server);
|
|
1204
|
+
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1205
|
+
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1206
|
+
const resolved = path.resolve(params.image_path);
|
|
1207
|
+
let buf: Buffer;
|
|
1208
|
+
try {
|
|
1209
|
+
buf = await readRequiredFile(resolved);
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
return toolError(`Could not read '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1212
|
+
}
|
|
1213
|
+
const auto = await pickModelByLabels(config, ["edit", "image"]);
|
|
1214
|
+
if (auto === null) return toolError(unreachableMessage(config));
|
|
1215
|
+
const model = params.model || config.defaultImageModel || auto;
|
|
1216
|
+
if (!model) return toolError("No image model found on the lemonade server.");
|
|
1217
|
+
const form = new FormData();
|
|
1218
|
+
form.append("model", model);
|
|
1219
|
+
form.append("response_format", "b64_json");
|
|
1220
|
+
if (params.size) form.append("size", params.size);
|
|
1221
|
+
form.append("image", new File([buf], path.basename(resolved), { type: "image/png" }));
|
|
1222
|
+
return postFormSaveImage(config, config.imageVariationPath, form, "image-variation", signal);
|
|
1223
|
+
},
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
// ── upscale_image ───────────────────────────────────────────────────────
|
|
1227
|
+
pi.registerTool({
|
|
1228
|
+
name: "upscale_image",
|
|
1229
|
+
label: "Upscale Image",
|
|
1230
|
+
description:
|
|
1231
|
+
"Upscale an image 4x with Real-ESRGAN on the lemonade server " +
|
|
1232
|
+
"(models: RealESRGAN-x4plus, RealESRGAN-x4plus-anime). Saves the upscaled PNG and returns its path.",
|
|
1233
|
+
promptSnippet: "[lemonade] Upscale images 4x with Real-ESRGAN",
|
|
1234
|
+
parameters: Type.Object({
|
|
1235
|
+
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1236
|
+
image_path: Type.String({ description: "Path to the image to upscale (PNG)." }),
|
|
1237
|
+
model: Type.Optional(Type.String({ description: `Upscale model id. Default: ${DEFAULT_CONFIG.defaultUpscaleModel}.` })),
|
|
1238
|
+
}),
|
|
1239
|
+
async execute(_toolCallId, params, signal) {
|
|
1240
|
+
const instCfg = instanceView(gconfig, params.server);
|
|
1241
|
+
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1242
|
+
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1243
|
+
const resolved = path.resolve(params.image_path);
|
|
1244
|
+
let buf: Buffer;
|
|
1245
|
+
try {
|
|
1246
|
+
buf = await readRequiredFile(resolved);
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
return toolError(`Could not read '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1249
|
+
}
|
|
1250
|
+
const model = params.model ?? config.defaultUpscaleModel;
|
|
1251
|
+
return postJsonSaveImage(
|
|
1252
|
+
config,
|
|
1253
|
+
config.imageUpscalePath,
|
|
1254
|
+
{ image: buf.toString("base64"), model },
|
|
1255
|
+
"image-upscaled",
|
|
1256
|
+
signal,
|
|
1257
|
+
`model: ${model}`
|
|
1258
|
+
);
|
|
1259
|
+
},
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
// ── text_to_speech ──────────────────────────────────────────────────────
|
|
1263
|
+
pi.registerTool({
|
|
1264
|
+
name: "text_to_speech",
|
|
1265
|
+
label: "Text to Speech",
|
|
1266
|
+
description:
|
|
1267
|
+
"Convert text to spoken audio using a TTS model on the lemonade server " +
|
|
1268
|
+
"(e.g. OpenMOSS-TTS). Saves the audio file and returns its path.",
|
|
1269
|
+
promptSnippet: "[lemonade] Convert text to spoken audio (TTS), saves mp3/wav to disk",
|
|
1270
|
+
parameters: Type.Object({
|
|
1271
|
+
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1272
|
+
text: Type.String({ description: "The text to speak." }),
|
|
1273
|
+
model: Type.Optional(Type.String({ description: "TTS model id. Omit to auto-pick from catalog." })),
|
|
1274
|
+
voice: Type.Optional(Type.String({ description: "Voice name (e.g. 'alloy', 'af_sky')." })),
|
|
1275
|
+
speed: Type.Optional(Type.Number({ description: "Speaking speed (default 1.0)." })),
|
|
1276
|
+
response_format: Type.Optional(Type.String({ description: "Output format: mp3 (default), wav, opus, pcm." })),
|
|
1277
|
+
}),
|
|
1278
|
+
async execute(_toolCallId, params, signal) {
|
|
1279
|
+
const instCfg = instanceView(gconfig, params.server);
|
|
1280
|
+
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1281
|
+
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1282
|
+
const auto = await pickModelByLabels(config, ["tts"]);
|
|
1283
|
+
if (auto === null) return toolError(unreachableMessage(config));
|
|
1284
|
+
const model = params.model || auto;
|
|
1285
|
+
if (!model) return toolError("No TTS model found on the lemonade server. Pull one first (e.g. OpenMOSS-TTS).");
|
|
1286
|
+
const fmt = params.response_format ?? "mp3";
|
|
1287
|
+
const body: Record<string, unknown> = { model, input: params.text, response_format: fmt };
|
|
1288
|
+
if (params.voice) body.voice = params.voice;
|
|
1289
|
+
if (params.speed) body.speed = params.speed;
|
|
1290
|
+
return postJsonSaveBinary(config, config.speechPath, body, "speech", fmt === "pcm" ? "pcm" : fmt, signal, `model: ${model}`);
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
// ── generate_audio (music / sound effects) ─────────────────────────────
|
|
1295
|
+
pi.registerTool({
|
|
1296
|
+
name: "generate_audio",
|
|
1297
|
+
label: "Generate Audio",
|
|
1298
|
+
description:
|
|
1299
|
+
"Generate music or sound effects from a text prompt using audio-generation models on " +
|
|
1300
|
+
"the lemonade server (e.g. ACE-Step-Music, ThinkSound-SFX). For music with vocals, " +
|
|
1301
|
+
"pass lyrics with section tags like [verse] and [chorus]. Saves a wav file and returns its path.",
|
|
1302
|
+
promptSnippet: "[lemonade] Generate music or sound effects from text prompts (ACE-Step / ThinkSound), saves wav",
|
|
1303
|
+
parameters: Type.Object({
|
|
1304
|
+
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1305
|
+
prompt: Type.String({ description: "Style description: genre, mood, tempo, instruments, voice character." }),
|
|
1306
|
+
model: Type.Optional(Type.String({ description: "Audio model id (e.g. ThinkSound-SFX, ACE-Step-Music). Omit to auto-pick." })),
|
|
1307
|
+
duration: Type.Optional(Type.Number({ description: "Clip length in seconds." })),
|
|
1308
|
+
lyrics: Type.Optional(Type.String({ description: "Lyrics to sing (music models only). Omit for instrumental." })),
|
|
1309
|
+
vocal_language: Type.Optional(Type.String({ description: "BCP-47 language of lyrics, e.g. 'en' (music models only)." })),
|
|
1310
|
+
seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility." })),
|
|
1311
|
+
}),
|
|
1312
|
+
async execute(_toolCallId, params, signal) {
|
|
1313
|
+
const instCfg = instanceView(gconfig, params.server);
|
|
1314
|
+
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1315
|
+
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1316
|
+
const auto = await pickModelByLabels(config, ["music", "sfx", "audio", "tts-audio"]);
|
|
1317
|
+
if (auto === null) return toolError(unreachableMessage(config));
|
|
1318
|
+
const model = params.model || auto;
|
|
1319
|
+
if (!model) return toolError("No audio-generation model found on the lemonade server. Pull one first (e.g. ThinkSound-SFX).");
|
|
1320
|
+
const body: Record<string, unknown> = { model, prompt: params.prompt, response_format: "wav" };
|
|
1321
|
+
if (params.duration) body.duration = params.duration;
|
|
1322
|
+
if (params.lyrics) body.lyrics = params.lyrics;
|
|
1323
|
+
if (params.vocal_language) body.vocal_language = params.vocal_language;
|
|
1324
|
+
if (params.seed !== undefined) body.seed = params.seed;
|
|
1325
|
+
return postJsonSaveBinary(config, config.audioGenerationPath, body, "audio-gen", "wav", signal, `model: ${model}`);
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
// ── generate_3d_model ──────────────────────────────────────────────────
|
|
1330
|
+
pi.registerTool({
|
|
1331
|
+
name: "generate_3d_model",
|
|
1332
|
+
label: "Generate 3D Model",
|
|
1333
|
+
description:
|
|
1334
|
+
"Generate a textured 3D mesh (.glb) from an image using the TRELLIS-3D model on the " +
|
|
1335
|
+
"lemonade server. Saves the .glb file and returns its path. Can take minutes.",
|
|
1336
|
+
promptSnippet: "[lemonade] Generate textured 3D meshes (.glb) from images with TRELLIS",
|
|
1337
|
+
parameters: Type.Object({
|
|
1338
|
+
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1339
|
+
image_path: Type.String({ description: "Path to the input image (PNG/JPEG/BMP/GIF)." }),
|
|
1340
|
+
model: Type.Optional(Type.String({ description: "3D model id, e.g. TRELLIS-3D. Omit to auto-pick." })),
|
|
1341
|
+
resolution: Type.Optional(Type.Number({ description: "Cascade resolution: 512 (default), 1024, or 1536." })),
|
|
1342
|
+
seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility." })),
|
|
1343
|
+
}),
|
|
1344
|
+
async execute(_toolCallId, params, signal) {
|
|
1345
|
+
const instCfg = instanceView(gconfig, params.server);
|
|
1346
|
+
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1347
|
+
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1348
|
+
const resolved = path.resolve(params.image_path);
|
|
1349
|
+
let buf: Buffer;
|
|
1350
|
+
try {
|
|
1351
|
+
buf = await readRequiredFile(resolved);
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
return toolError(`Could not read '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1354
|
+
}
|
|
1355
|
+
const auto = await pickModelByLabels(config, ["3d"]);
|
|
1356
|
+
if (auto === null) return toolError(unreachableMessage(config));
|
|
1357
|
+
const model = params.model || auto;
|
|
1358
|
+
if (!model) return toolError("No 3D-generation model found on the lemonade server. Pull one first (e.g. TRELLIS-3D).");
|
|
1359
|
+
const body: Record<string, unknown> = { model, image: `data:image/png;base64,${buf.toString("base64")}` };
|
|
1360
|
+
if (params.resolution) body.resolution = params.resolution;
|
|
1361
|
+
if (params.seed !== undefined) body.seed = params.seed;
|
|
1362
|
+
return postJsonSaveBinary(config, config.mesh3dPath, body, "mesh3d", "glb", signal);
|
|
1363
|
+
},
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
// ── classify_text ────────────────────────────────────────────────────
|
|
1367
|
+
pi.registerTool({
|
|
1368
|
+
name: "classify_text",
|
|
1369
|
+
label: "Classify Text",
|
|
1370
|
+
description:
|
|
1371
|
+
"Classify input TEXT with an encoder-classifier model on the lemonade server " +
|
|
1372
|
+
"(recipe: onnxruntime). Each classifier answers exactly one question, decided by " +
|
|
1373
|
+
"its baked-in label set, and the response's labels reveal that universe — if they " +
|
|
1374
|
+
"don't fit the question, retry with a different model. Text-only by architecture: " +
|
|
1375
|
+
"image questions go to vision-capable chat models (read the image), audio must be " +
|
|
1376
|
+
"transcribed first (transcribe_audio). " +
|
|
1377
|
+
(classifierHint ||
|
|
1378
|
+
"Common classifier types: phishing/PII/prompt-injection detection. Pull via /lemonade-setup.") +
|
|
1379
|
+
" Returns all labels with confidence scores, ranked highest to lowest.",
|
|
1380
|
+
promptSnippet:
|
|
1381
|
+
"[lemonade] Classify text through safety/phishing/PII-style classifier models — ranked label confidences",
|
|
1382
|
+
parameters: Type.Object({
|
|
1383
|
+
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1384
|
+
input: Type.String({ description: "The text to classify." }),
|
|
1385
|
+
model: Type.Optional(
|
|
1386
|
+
Type.String({ description: "Classifier model id (recipe onnxruntime). Omit to auto-pick." })
|
|
1387
|
+
),
|
|
1388
|
+
top_k: Type.Optional(
|
|
1389
|
+
Type.Number({ description: "Only return the highest-scoring k labels." })
|
|
1390
|
+
),
|
|
1391
|
+
}),
|
|
1392
|
+
async execute(_toolCallId, params, signal) {
|
|
1393
|
+
const instCfg = instanceView(gconfig, params.server);
|
|
1394
|
+
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1395
|
+
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1396
|
+
const auto = await pickModelByRecipe(config, "onnxruntime");
|
|
1397
|
+
if (auto === null) return toolError(unreachableMessage(config));
|
|
1398
|
+
const model = params.model || config.defaultClassifierModel || auto;
|
|
1399
|
+
if (!model) {
|
|
1400
|
+
return toolError(
|
|
1401
|
+
"No classifier model (onnxruntime recipe) found on the lemonade server. " +
|
|
1402
|
+
"Pull one first, e.g. Bert-Phishing-ONNX or Phishing-Email-Detection-ONNX."
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
const body: Record<string, unknown> = { model, input: params.input };
|
|
1406
|
+
if (params.top_k) body.top_k = params.top_k;
|
|
1407
|
+
const res = await toolFetch(config, config.classifyPath, {
|
|
1408
|
+
method: "POST",
|
|
1409
|
+
headers: { ...authHeaders(config), "Content-Type": "application/json" },
|
|
1410
|
+
body: JSON.stringify(body),
|
|
1411
|
+
signal,
|
|
1412
|
+
});
|
|
1413
|
+
if (!res.ok) {
|
|
1414
|
+
return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
|
|
1415
|
+
}
|
|
1416
|
+
const payload = (await res.json()) as Record<string, unknown> & {
|
|
1417
|
+
labels?: Record<string, number>;
|
|
1418
|
+
model?: string;
|
|
1419
|
+
};
|
|
1420
|
+
const ranked = Object.entries(payload.labels ?? {})
|
|
1421
|
+
.sort((a, b) => b[1] - a[1])
|
|
1422
|
+
.map(([label, score]) => `${(score * 100).toFixed(3).padStart(7)}% ${label}`);
|
|
1423
|
+
// Some classifiers (token-classification, e.g. PII span detection) return
|
|
1424
|
+
// extra structure beyond the flat label map — pass it through verbatim.
|
|
1425
|
+
const extras = Object.keys(payload).filter((k) => !["labels", "model", "object"].includes(k));
|
|
1426
|
+
const extraText = extras.length
|
|
1427
|
+
? "\n" + JSON.stringify(Object.fromEntries(extras.map((k) => [k, payload[k]])), null, 2)
|
|
1428
|
+
: "";
|
|
1429
|
+
const text =
|
|
1430
|
+
`Classification by ${payload.model ?? model}:\n` +
|
|
1431
|
+
(ranked.length ? ranked.join("\n") : "(no labels returned)") +
|
|
1432
|
+
extraText;
|
|
1433
|
+
return { content: [{ type: "text", text }], details: { model } };
|
|
1434
|
+
},
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// ─── Lemonade status bar (belowEditor widget) ──────────────────────────────
|
|
1439
|
+
|
|
1440
|
+
/** Lemonade instance from a pi model: provider "lemonade-<instance>" → "<instance>".
|
|
1441
|
+
* The picker address "lemonade-<instance>/<model>" is display-only — the
|
|
1442
|
+
* Model object keeps provider and id in separate fields. */
|
|
1443
|
+
function instanceFromModel(model: { id?: string; provider?: string } | undefined): string | null {
|
|
1444
|
+
if (typeof model?.provider !== "string" || !model.provider.startsWith("lemonade-")) return null;
|
|
1445
|
+
const instance = model.provider.slice("lemonade-".length);
|
|
1446
|
+
return instance || null;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** Metrics behind the bar. tok/s + cache % are session-local (derived from
|
|
1450
|
+
* pi's own message events); busy/queue, CPU/GPU and VRAM are polled from the
|
|
1451
|
+
* instance. Segments never appear or vanish — every segment is permanent and
|
|
1452
|
+
* shows a "…" placeholder until its first value lands. */
|
|
1453
|
+
interface BarMetrics {
|
|
1454
|
+
toksPerSec: number | null; // last completed assistant message, locally timed
|
|
1455
|
+
cachePct: number | null; // its prefix-cache hit rate (usage.cacheRead / prompt)
|
|
1456
|
+
busy: string | null; // "idle" | "busy" | "busy ·Nq" | "offline"
|
|
1457
|
+
cpuPct: number | null;
|
|
1458
|
+
gpuPct: number | null;
|
|
1459
|
+
npuPct: number | null;
|
|
1460
|
+
npuSupported: boolean | null; // null = unknown yet; false = box has no NPU (segment dropped)
|
|
1461
|
+
vramGb: number | null;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Below-editor status bar, shown ONLY while a lemonade model is active.
|
|
1466
|
+
* Appears as its own row (ctx.ui.setWidget, placement "belowEditor") — the
|
|
1467
|
+
* built-in footer and any other extension's custom footer are untouched.
|
|
1468
|
+
* `model_select`/`session_start` add or remove the row as the active model
|
|
1469
|
+
* changes; the instance name comes from the model id itself.
|
|
1470
|
+
*/
|
|
1471
|
+
function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
|
|
1472
|
+
const KEY = "lemonade-status-bar";
|
|
1473
|
+
type ExtCtx = import("@earendil-works/pi-coding-agent").ExtensionContext;
|
|
1474
|
+
|
|
1475
|
+
const metrics: BarMetrics = { toksPerSec: null, cachePct: null, busy: null, cpuPct: null, gpuPct: null, npuPct: null, npuSupported: null, vramGb: null };
|
|
1476
|
+
let activeInstance: string | null = null;
|
|
1477
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
1478
|
+
let tuiRef: { requestRender(): void } | undefined;
|
|
1479
|
+
let msgStartAt: number | null = null;
|
|
1480
|
+
|
|
1481
|
+
const stopTimer = () => {
|
|
1482
|
+
if (timer !== undefined) {
|
|
1483
|
+
clearInterval(timer);
|
|
1484
|
+
timer = undefined;
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
const resetLocal = () => {
|
|
1489
|
+
metrics.toksPerSec = null;
|
|
1490
|
+
metrics.cachePct = null;
|
|
1491
|
+
metrics.busy = null;
|
|
1492
|
+
metrics.cpuPct = null;
|
|
1493
|
+
metrics.gpuPct = null;
|
|
1494
|
+
metrics.npuPct = null;
|
|
1495
|
+
metrics.npuSupported = null;
|
|
1496
|
+
metrics.vramGb = null;
|
|
1497
|
+
msgStartAt = null; // a stream started under another/removed row must not leak into the next
|
|
1498
|
+
};
|
|
1499
|
+
|
|
1500
|
+
/** One poll of the active instance: busy/queue, CPU/GPU, VRAM. Transient
|
|
1501
|
+
* failures mark the box "offline" but keep the last-known gauge values —
|
|
1502
|
+
* segments never vanish. Results are discarded if the row switched
|
|
1503
|
+
* instances mid-fetch. */
|
|
1504
|
+
const tick = async (instance: string): Promise<void> => {
|
|
1505
|
+
if (activeInstance !== instance) return; // switched away before we started
|
|
1506
|
+
const view = instanceView(configRef.current, instance);
|
|
1507
|
+
if (typeof view === "string") {
|
|
1508
|
+
if (activeInstance === instance) metrics.busy = "offline"; // instance vanished mid-session
|
|
1509
|
+
tuiRef?.requestRender();
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
const [health, sys, metricsBody] = await Promise.all([
|
|
1513
|
+
fetchHealth(view),
|
|
1514
|
+
fetchJsonOrNull<SystemStats>(view, "/v1/system-stats"),
|
|
1515
|
+
fetchTextOrNull(view, "/metrics"),
|
|
1516
|
+
]);
|
|
1517
|
+
if (activeInstance !== instance) return; // row switched while fetching: discard
|
|
1518
|
+
if (!health) {
|
|
1519
|
+
metrics.busy = "offline"; // gauges keep their last-known values
|
|
1520
|
+
} else {
|
|
1521
|
+
let queue = 0;
|
|
1522
|
+
if (metricsBody) {
|
|
1523
|
+
const pm = parsePrometheus(metricsBody);
|
|
1524
|
+
queue =
|
|
1525
|
+
(pm.get("lemonade_llamacpp_requests_processing") ?? 0) +
|
|
1526
|
+
(pm.get("lemonade_llamacpp_requests_deferred") ?? 0);
|
|
1527
|
+
}
|
|
1528
|
+
const busyModel = (health.all_models_loaded ?? []).some((m) => m.is_busy || m.is_streaming);
|
|
1529
|
+
metrics.busy = busyModel ? (queue > 1 ? `busy ·${queue}q` : "busy") : "idle";
|
|
1530
|
+
if (typeof sys?.cpu_percent === "number") metrics.cpuPct = sys.cpu_percent;
|
|
1531
|
+
if (typeof sys?.gpu_percent === "number") metrics.gpuPct = sys.gpu_percent;
|
|
1532
|
+
if (typeof sys?.npu_percent === "number") {
|
|
1533
|
+
metrics.npuPct = sys.npu_percent;
|
|
1534
|
+
metrics.npuSupported = true;
|
|
1535
|
+
} else if (sys) {
|
|
1536
|
+
metrics.npuSupported = false; // box reported system-stats with no NPU
|
|
1537
|
+
}
|
|
1538
|
+
if (typeof sys?.vram_gb === "number") metrics.vramGb = sys.vram_gb;
|
|
1539
|
+
}
|
|
1540
|
+
tuiRef?.requestRender();
|
|
1541
|
+
};
|
|
1542
|
+
|
|
1543
|
+
const teardown = (ctx: ExtCtx) => {
|
|
1544
|
+
stopTimer();
|
|
1545
|
+
tuiRef = undefined;
|
|
1546
|
+
activeInstance = null;
|
|
1547
|
+
resetLocal();
|
|
1548
|
+
try {
|
|
1549
|
+
ctx.ui.setWidget(KEY, undefined);
|
|
1550
|
+
} catch {
|
|
1551
|
+
/* widget never installed */
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
const install = (ctx: ExtCtx, instance: string) => {
|
|
1556
|
+
stopTimer();
|
|
1557
|
+
resetLocal();
|
|
1558
|
+
activeInstance = instance;
|
|
1559
|
+
ctx.ui.setWidget(
|
|
1560
|
+
KEY,
|
|
1561
|
+
(tui, theme) => {
|
|
1562
|
+
tuiRef = tui;
|
|
1563
|
+
const component = {
|
|
1564
|
+
render: (width: number) => {
|
|
1565
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
1566
|
+
// Fixed segment layout — segments never appear or vanish, only
|
|
1567
|
+
// their values change. Polled segments are omitted entirely only
|
|
1568
|
+
// when polling is disabled (barPollMs: 0), not while pending.
|
|
1569
|
+
const polls = configRef.current.barPollMs > 0;
|
|
1570
|
+
const parts = [theme.fg("accent", `🍋 ${instance}`)];
|
|
1571
|
+
parts.push(
|
|
1572
|
+
dim(metrics.toksPerSec !== null ? `${metrics.toksPerSec.toFixed(1)} tok/s` : "… tok/s")
|
|
1573
|
+
);
|
|
1574
|
+
parts.push(
|
|
1575
|
+
dim(metrics.cachePct !== null ? `cache-hit ${metrics.cachePct.toFixed(0)}%` : "cache-hit …%")
|
|
1576
|
+
);
|
|
1577
|
+
if (polls) {
|
|
1578
|
+
if (metrics.busy === null) parts.push(dim("…"));
|
|
1579
|
+
else if (metrics.busy === "idle") parts.push(dim("idle"));
|
|
1580
|
+
else parts.push(theme.fg("warning", metrics.busy));
|
|
1581
|
+
parts.push(dim(`CPU ${metrics.cpuPct !== null ? `${metrics.cpuPct.toFixed(0)}%` : "…"}`));
|
|
1582
|
+
parts.push(dim(`GPU ${metrics.gpuPct !== null ? `${metrics.gpuPct.toFixed(0)}%` : "…"}`));
|
|
1583
|
+
// Boxes without an NPU report npu_percent: null — the segment is
|
|
1584
|
+
// dropped permanently on first sight, never flickering.
|
|
1585
|
+
if (metrics.npuSupported !== false)
|
|
1586
|
+
parts.push(
|
|
1587
|
+
dim(`NPU ${metrics.npuPct !== null ? `${metrics.npuPct.toFixed(0)}%` : "…"}`)
|
|
1588
|
+
);
|
|
1589
|
+
parts.push(dim(`VRAM ${metrics.vramGb !== null ? `${metrics.vramGb.toFixed(1)}G` : "…"}`));
|
|
1590
|
+
}
|
|
1591
|
+
return [truncateToWidth(parts.join(theme.fg("dim", " · ")), width)];
|
|
1592
|
+
},
|
|
1593
|
+
invalidate: () => {},
|
|
1594
|
+
dispose: () => {
|
|
1595
|
+
stopTimer();
|
|
1596
|
+
tuiRef = undefined;
|
|
1597
|
+
},
|
|
1598
|
+
};
|
|
1599
|
+
const pollMs = configRef.current.barPollMs;
|
|
1600
|
+
if (pollMs > 0) {
|
|
1601
|
+
void tick(instance); // prime immediately, then on the interval
|
|
1602
|
+
timer = setInterval(() => void tick(instance), Math.max(1000, pollMs));
|
|
1603
|
+
}
|
|
1604
|
+
return component;
|
|
1605
|
+
},
|
|
1606
|
+
{ placement: "belowEditor" }
|
|
1607
|
+
);
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
/** Add, swap, or remove the row to match the active model. The model comes
|
|
1611
|
+
* from the event when it carries one (model_select), else from ctx. */
|
|
1612
|
+
const sync = (ctx: ExtCtx, model?: { id?: string; provider?: string }) => {
|
|
1613
|
+
if (!ctx.hasUI) return;
|
|
1614
|
+
const instance = instanceFromModel(model ?? ctx.model);
|
|
1615
|
+
if (!instance || !configRef.current.statusBar) {
|
|
1616
|
+
if (activeInstance !== null) teardown(ctx);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
if (activeInstance === instance) return; // already showing this instance
|
|
1620
|
+
install(ctx, instance);
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1623
|
+
pi.on("session_start", (_e, ctx) => sync(ctx));
|
|
1624
|
+
pi.on("model_select", (e, ctx) =>
|
|
1625
|
+
sync(ctx, (e as { model?: { id?: string; provider?: string } }).model)
|
|
1626
|
+
);
|
|
1627
|
+
// Safety net: if ctx.model wasn't resolvable at session_start, the first
|
|
1628
|
+
// turn re-checks. sync() is idempotent — it early-returns when unchanged.
|
|
1629
|
+
pi.on("turn_start", (_e, ctx) => sync(ctx));
|
|
1630
|
+
|
|
1631
|
+
// Session-local metrics: tok/s is timed from message_start → message_end and
|
|
1632
|
+
// divided by usage.output; cache hit % is usage.cacheRead / (cacheRead + input)
|
|
1633
|
+
// — pi-ai maps llama.cpp prefix-cache hits onto usage.cacheRead, so this is
|
|
1634
|
+
// always the session's own request, never the server's last-client stats.
|
|
1635
|
+
pi.on("message_start", (e) => {
|
|
1636
|
+
if (activeInstance !== null && (e as { message?: { role?: string } }).message?.role === "assistant")
|
|
1637
|
+
msgStartAt = Date.now();
|
|
1638
|
+
});
|
|
1639
|
+
pi.on("message_end", (e) => {
|
|
1640
|
+
if (activeInstance === null) return;
|
|
1641
|
+
const m = e as {
|
|
1642
|
+
message?: { role?: string; usage?: { input?: number; output?: number; cacheRead?: number } };
|
|
1643
|
+
};
|
|
1644
|
+
if (m.message?.role !== "assistant") return;
|
|
1645
|
+
const u = m.message.usage;
|
|
1646
|
+
if (msgStartAt !== null && u && typeof u.output === "number" && u.output > 0) {
|
|
1647
|
+
const secs = (Date.now() - msgStartAt) / 1000;
|
|
1648
|
+
if (secs > 0.1) metrics.toksPerSec = u.output / secs;
|
|
1649
|
+
}
|
|
1650
|
+
if (u) {
|
|
1651
|
+
const prompt = (u.cacheRead ?? 0) + (u.input ?? 0);
|
|
1652
|
+
metrics.cachePct = prompt > 0 ? ((u.cacheRead ?? 0) / prompt) * 100 : null;
|
|
1653
|
+
}
|
|
1654
|
+
msgStartAt = null;
|
|
1655
|
+
tuiRef?.requestRender();
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// ─── /lemonade-setup TUI ────────────────────────────────────────────────────
|
|
1660
|
+
|
|
1661
|
+
type UiCtx = { ui: import("@earendil-works/pi-coding-agent").ExtensionContext["ui"] };
|
|
1662
|
+
|
|
1663
|
+
/** Menu item for the SelectList-based menus. pi-tui's SelectItem is
|
|
1664
|
+
* string-valued; this local shape restores a typed `value` so menu<T>()
|
|
1665
|
+
* callers get literal-union results instead of bare strings. */
|
|
1666
|
+
interface MenuItem<T extends string> {
|
|
1667
|
+
value: T;
|
|
1668
|
+
label: string;
|
|
1669
|
+
description?: string;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/** Show a SelectList menu framed with DynamicBorder. Returns chosen value or null on Esc. */
|
|
1673
|
+
function menu<T extends string>(ctx: UiCtx, title: string, items: MenuItem<T>[]): Promise<T | null> {
|
|
1674
|
+
return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
|
|
1675
|
+
const container = new Container();
|
|
1676
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1677
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
1678
|
+
const list = new SelectList(items, Math.min(items.length, 12), {
|
|
1679
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
1680
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
1681
|
+
description: (t) => theme.fg("muted", t),
|
|
1682
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
1683
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
1684
|
+
});
|
|
1685
|
+
list.onSelect = (item) => done(item.value as T);
|
|
1686
|
+
list.onCancel = () => done(null);
|
|
1687
|
+
container.addChild(list);
|
|
1688
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc back"), 1, 0));
|
|
1689
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1690
|
+
return {
|
|
1691
|
+
render: (w) => container.render(w),
|
|
1692
|
+
invalidate: () => container.invalidate(),
|
|
1693
|
+
handleInput: (data) => { list.handleInput(data); tui.requestRender(); },
|
|
1694
|
+
};
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** Live-refresh wiring for the scrollable textView (status panel). */
|
|
1699
|
+
interface TextViewLive {
|
|
1700
|
+
/** Poll interval in ms; failed/empty refetches keep the last good snapshot. */
|
|
1701
|
+
pollMs?: number;
|
|
1702
|
+
refetch?: () => Promise<string[]>;
|
|
1703
|
+
/** Extra footer key hint (e.g. "n/p instance"), shown when multiple targets exist. */
|
|
1704
|
+
extraHint?: string;
|
|
1705
|
+
/** Hook for keys beyond scroll/quit; return true when the key was consumed. */
|
|
1706
|
+
onKey?: (data: string, api: { setTitle: (t: string) => void }) => boolean;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/** Full-screen framed text view; any key returns. `scrollable` adds keyboard
|
|
1710
|
+
* paging; `live` adds polling + a key hook (scrollable mode only). */
|
|
1711
|
+
async function textView(
|
|
1712
|
+
ctx: UiCtx,
|
|
1713
|
+
title: string,
|
|
1714
|
+
lines: string[],
|
|
1715
|
+
scrollable = false,
|
|
1716
|
+
live?: TextViewLive
|
|
1717
|
+
): Promise<void> {
|
|
1718
|
+
await ctx.ui.custom<null>((tui, theme, _kb, done) => {
|
|
1719
|
+
const container = new Container();
|
|
1720
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1721
|
+
const titleText = new Text(theme.fg("accent", theme.bold(title)), 1, 0);
|
|
1722
|
+
container.addChild(titleText);
|
|
1723
|
+
if (!scrollable) {
|
|
1724
|
+
for (const line of lines) container.addChild(new Text(line, 0, 0));
|
|
1725
|
+
const footer = new Text(theme.fg("dim", "press any key to return"), 1, 0);
|
|
1726
|
+
container.addChild(footer);
|
|
1727
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1728
|
+
return {
|
|
1729
|
+
render: (w) => container.render(w),
|
|
1730
|
+
invalidate: () => container.invalidate(),
|
|
1731
|
+
handleInput: () => { done(null); tui.requestRender(); },
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// Scrollable: render only a terminal-height window of the lines, with a
|
|
1736
|
+
// live position indicator. The main screen has no layout engine for this
|
|
1737
|
+
// custom view, so ScrollView is inert here — the window is sliced by hand.
|
|
1738
|
+
// The slice is wrap-aware: lines longer than the terminal width occupy
|
|
1739
|
+
// more than one rendered row, so the window is filled by *rows*, not by
|
|
1740
|
+
// line count (otherwise long lines push the footer off-screen on narrow
|
|
1741
|
+
// terminals and scrolling can stop short of the real end).
|
|
1742
|
+
const body = new Text("", 0, 0);
|
|
1743
|
+
container.addChild(body);
|
|
1744
|
+
const footer = new Text("", 1, 0);
|
|
1745
|
+
container.addChild(footer);
|
|
1746
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1747
|
+
|
|
1748
|
+
let cur = lines; // swapped in place by live refetches
|
|
1749
|
+
|
|
1750
|
+
// top border + title + footer + bottom border = 4 lines; keep one spare
|
|
1751
|
+
// row so word-wrap loss (wrap estimates are a lower bound) never pushes
|
|
1752
|
+
// the bottom border off-screen.
|
|
1753
|
+
const overhead = 5;
|
|
1754
|
+
const viewport = () => Math.max(3, tui.terminal.rows - overhead);
|
|
1755
|
+
const rowsOf = (l: string) => Math.max(1, Math.ceil(l.length / Math.max(10, tui.terminal.columns)));
|
|
1756
|
+
|
|
1757
|
+
// Row offset of each line's first rendered row; offsets[cur.length] = total rows.
|
|
1758
|
+
const rowOffsets = (): number[] => {
|
|
1759
|
+
const off: number[] = [];
|
|
1760
|
+
let acc = 0;
|
|
1761
|
+
for (const l of cur) {
|
|
1762
|
+
off.push(acc);
|
|
1763
|
+
acc += rowsOf(l);
|
|
1764
|
+
}
|
|
1765
|
+
off.push(acc);
|
|
1766
|
+
return off;
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
let top = 0;
|
|
1770
|
+
/** Largest line index that still leaves a full viewport of rows below. */
|
|
1771
|
+
const maxTop = () => {
|
|
1772
|
+
const off = rowOffsets();
|
|
1773
|
+
const vh = viewport();
|
|
1774
|
+
let i = cur.length - 1;
|
|
1775
|
+
while (i > 0 && off[cur.length] - off[i] < vh) i--;
|
|
1776
|
+
return i;
|
|
1777
|
+
};
|
|
1778
|
+
/** Line index `byRows` rendered rows down/up from `top`. */
|
|
1779
|
+
const advance = (byRows: number): number => {
|
|
1780
|
+
const off = rowOffsets();
|
|
1781
|
+
const target = off[top] + byRows;
|
|
1782
|
+
let i = top;
|
|
1783
|
+
while (i < cur.length - 1 && off[i] < target) i++;
|
|
1784
|
+
return i;
|
|
1785
|
+
};
|
|
1786
|
+
const retreat = (byRows: number): number => {
|
|
1787
|
+
const off = rowOffsets();
|
|
1788
|
+
const target = Math.max(0, off[top] - byRows);
|
|
1789
|
+
let i = top;
|
|
1790
|
+
while (i > 0 && off[i] > target) i--;
|
|
1791
|
+
return i;
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1794
|
+
const refresh = () => {
|
|
1795
|
+
const off = rowOffsets();
|
|
1796
|
+
const vh = viewport();
|
|
1797
|
+
top = Math.min(top, maxTop());
|
|
1798
|
+
const slice: string[] = [];
|
|
1799
|
+
let used = 0;
|
|
1800
|
+
let j = top;
|
|
1801
|
+
while (j < cur.length) {
|
|
1802
|
+
const r = rowsOf(cur[j]);
|
|
1803
|
+
if (used + r > vh && slice.length) break; // always show at least the top line
|
|
1804
|
+
used += r;
|
|
1805
|
+
slice.push(cur[j]);
|
|
1806
|
+
j++;
|
|
1807
|
+
}
|
|
1808
|
+
body.setText(slice.join("\n"));
|
|
1809
|
+
// Hint ladder: the full hint (scroll keys + instance keys + quit keys +
|
|
1810
|
+
// position) is ~80 chars and wraps on 80-col terminals, so progressively
|
|
1811
|
+
// shorter variants are tried until one fits the usable width. Keys are
|
|
1812
|
+
// unaffected — the hint is best-effort documentation.
|
|
1813
|
+
const base = [...(live?.extraHint ? [live.extraHint] : []), "q/esc/enter return"].join(" · ");
|
|
1814
|
+
const pos = `lines ${top + 1}–${top + slice.length} of ${cur.length}`;
|
|
1815
|
+
const scrollableNow = off[cur.length] > vh;
|
|
1816
|
+
const candidates = scrollableNow
|
|
1817
|
+
? [
|
|
1818
|
+
`↑↓/jk/pgup·pgdn/home/end · ${base} · ${pos}`,
|
|
1819
|
+
`↑↓/jk/pgup·pgdn · ${base} · ${pos}`,
|
|
1820
|
+
`↑↓/jk · ${base}`,
|
|
1821
|
+
base,
|
|
1822
|
+
]
|
|
1823
|
+
: [base, "q/esc return"];
|
|
1824
|
+
const budget = Math.max(10, tui.terminal.columns) - 2; // Text paddingX 1 each side
|
|
1825
|
+
const hint = candidates.find((c) => c.length <= budget) ?? candidates[candidates.length - 1];
|
|
1826
|
+
footer.setText(theme.fg("dim", hint));
|
|
1827
|
+
};
|
|
1828
|
+
refresh();
|
|
1829
|
+
|
|
1830
|
+
// Live polling: swap in fresh lines on each tick. Transient fetch failures
|
|
1831
|
+
// keep the last good snapshot on screen; the timer dies with the view.
|
|
1832
|
+
// Ticks coalesce with a trailing edge: a tick requested while one is in
|
|
1833
|
+
// flight (fast n/p cycling) runs once after it, with the latest target.
|
|
1834
|
+
let inFlight = false;
|
|
1835
|
+
let pending = false;
|
|
1836
|
+
const tick = async (): Promise<void> => {
|
|
1837
|
+
if (!live?.refetch) return;
|
|
1838
|
+
if (inFlight) {
|
|
1839
|
+
pending = true;
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
inFlight = true;
|
|
1843
|
+
try {
|
|
1844
|
+
const next = await live.refetch();
|
|
1845
|
+
if (next && next.length) {
|
|
1846
|
+
cur = next;
|
|
1847
|
+
refresh();
|
|
1848
|
+
tui.requestRender();
|
|
1849
|
+
}
|
|
1850
|
+
} catch {
|
|
1851
|
+
/* keep showing the last good snapshot */
|
|
1852
|
+
} finally {
|
|
1853
|
+
inFlight = false;
|
|
1854
|
+
if (pending) {
|
|
1855
|
+
pending = false;
|
|
1856
|
+
void tick();
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
1861
|
+
if (live?.refetch) timer = setInterval(() => void tick(), Math.max(250, live.pollMs ?? 2000));
|
|
1862
|
+
const cleanup = () => {
|
|
1863
|
+
if (timer !== undefined) {
|
|
1864
|
+
clearInterval(timer);
|
|
1865
|
+
timer = undefined;
|
|
1866
|
+
}
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1869
|
+
return {
|
|
1870
|
+
render: (w) => container.render(w),
|
|
1871
|
+
invalidate: () => container.invalidate(),
|
|
1872
|
+
handleInput: (data) => {
|
|
1873
|
+
// Letters have no Key.* members (Key only covers special/symbol keys);
|
|
1874
|
+
// "j"/"k"/"q" are valid bare KeyIds. Never let an input error become an
|
|
1875
|
+
// uncaught exception — it would take down the whole pi process.
|
|
1876
|
+
try {
|
|
1877
|
+
const page = () => Math.max(1, viewport() - 1);
|
|
1878
|
+
if (matchesKey(data, Key.up) || matchesKey(data, "k")) top = retreat(1);
|
|
1879
|
+
else if (matchesKey(data, Key.down) || matchesKey(data, "j")) top = Math.min(maxTop(), advance(1));
|
|
1880
|
+
else if (matchesKey(data, Key.pageUp)) top = retreat(page());
|
|
1881
|
+
else if (matchesKey(data, Key.pageDown)) top = Math.min(maxTop(), advance(page()));
|
|
1882
|
+
else if (matchesKey(data, Key.home)) top = 0;
|
|
1883
|
+
else if (matchesKey(data, Key.end)) top = maxTop();
|
|
1884
|
+
else if (
|
|
1885
|
+
matchesKey(data, Key.escape) ||
|
|
1886
|
+
matchesKey(data, Key.enter) ||
|
|
1887
|
+
matchesKey(data, "q")
|
|
1888
|
+
) {
|
|
1889
|
+
cleanup();
|
|
1890
|
+
done(null);
|
|
1891
|
+
tui.requestRender();
|
|
1892
|
+
return;
|
|
1893
|
+
} else if (
|
|
1894
|
+
live?.onKey &&
|
|
1895
|
+
live.onKey(data, {
|
|
1896
|
+
setTitle: (t: string) => titleText.setText(theme.fg("accent", theme.bold(t))),
|
|
1897
|
+
})
|
|
1898
|
+
) {
|
|
1899
|
+
void tick(); // target changed — refresh immediately, poll continues on it
|
|
1900
|
+
}
|
|
1901
|
+
refresh();
|
|
1902
|
+
tui.requestRender();
|
|
1903
|
+
} catch {
|
|
1904
|
+
// A failed key match must not kill the process — swallow and continue.
|
|
1905
|
+
}
|
|
1906
|
+
},
|
|
1907
|
+
dispose: cleanup,
|
|
1908
|
+
handleMouse: (event: TuiMouseEvent): TuiMouseEventResult | undefined => {
|
|
1909
|
+
if (event.type !== "wheel" || !event.wheelDelta) return undefined;
|
|
1910
|
+
top =
|
|
1911
|
+
event.wheelDelta > 0
|
|
1912
|
+
? Math.min(maxTop(), advance(event.wheelDelta))
|
|
1913
|
+
: retreat(-event.wheelDelta);
|
|
1914
|
+
refresh();
|
|
1915
|
+
tui.requestRender();
|
|
1916
|
+
return { handled: true };
|
|
1917
|
+
},
|
|
1918
|
+
};
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/**
|
|
1923
|
+
* Live download-progress view over a server-owned download job.
|
|
1924
|
+
* Polls GET /v1/downloads once per second; Esc sends a cancel via
|
|
1925
|
+
* /v1/downloads/control. Resolves when the job completes, errors,
|
|
1926
|
+
* disappears (treated as completed), or the user cancels.
|
|
1927
|
+
*/
|
|
1928
|
+
async function trackDownload(
|
|
1929
|
+
ctx: UiCtx,
|
|
1930
|
+
config: LemonadeConfig,
|
|
1931
|
+
jobId: string,
|
|
1932
|
+
label: string
|
|
1933
|
+
): Promise<"complete" | "cancelled" | "error"> {
|
|
1934
|
+
const outcome = await ctx.ui.custom<"complete" | "cancelled" | "error">((tui, theme, _kb, done) => {
|
|
1935
|
+
const container = new Container();
|
|
1936
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1937
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(`Downloading ${label}`)), 1, 0));
|
|
1938
|
+
const statusLine = new Text("starting…", 0, 0);
|
|
1939
|
+
container.addChild(statusLine);
|
|
1940
|
+
container.addChild(new Text(theme.fg("dim", "esc cancel • progress updates every second"), 1, 0));
|
|
1941
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1942
|
+
|
|
1943
|
+
let finished = false;
|
|
1944
|
+
let seen = false;
|
|
1945
|
+
const finish = (v: "complete" | "cancelled" | "error") => {
|
|
1946
|
+
if (finished) return;
|
|
1947
|
+
finished = true;
|
|
1948
|
+
done(v);
|
|
1949
|
+
};
|
|
1950
|
+
|
|
1951
|
+
void (async () => {
|
|
1952
|
+
while (!finished) {
|
|
1953
|
+
const jobs = await listDownloads(config);
|
|
1954
|
+
const job = (jobs ?? []).find((j) => j.id === jobId);
|
|
1955
|
+
if (job) {
|
|
1956
|
+
seen = true;
|
|
1957
|
+
const pct = typeof job.percent === "number" ? `${job.percent.toFixed(1)}%` : "?";
|
|
1958
|
+
const bytes = `${formatBytes(job.cumulative_bytes_downloaded ?? job.bytes_downloaded)} / ${formatBytes(job.total_download_size ?? job.bytes_total)}`;
|
|
1959
|
+
statusLine.setText(
|
|
1960
|
+
`${job.status} — ${pct} (${bytes})${job.file ? ` — ${job.file}` : ""}` +
|
|
1961
|
+
`${job.file_index && job.total_files ? ` [file ${job.file_index}/${job.total_files}]` : ""}` +
|
|
1962
|
+
(job.error ? `\nerror: ${job.error.slice(0, 120)}` : "")
|
|
1963
|
+
);
|
|
1964
|
+
tui.requestRender();
|
|
1965
|
+
if (job.complete || job.status === "completed") return finish("complete");
|
|
1966
|
+
if (job.status === "error") return finish("error");
|
|
1967
|
+
if (job.status === "cancelled") return finish("cancelled");
|
|
1968
|
+
} else if (seen) {
|
|
1969
|
+
// Completed jobs are removed from the list after a short delay.
|
|
1970
|
+
return finish("complete");
|
|
1971
|
+
} else if (jobs !== null) {
|
|
1972
|
+
// Job not registered yet (server hasn't picked it up) — keep polling.
|
|
1973
|
+
} else {
|
|
1974
|
+
statusLine.setText("cannot reach server — press esc to cancel");
|
|
1975
|
+
tui.requestRender();
|
|
1976
|
+
}
|
|
1977
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1978
|
+
}
|
|
1979
|
+
})();
|
|
1980
|
+
|
|
1981
|
+
return {
|
|
1982
|
+
render: (w) => container.render(w),
|
|
1983
|
+
invalidate: () => container.invalidate(),
|
|
1984
|
+
handleInput: (data) => {
|
|
1985
|
+
if (matchesKey(data, Key.escape)) {
|
|
1986
|
+
statusLine.setText("cancelling…");
|
|
1987
|
+
tui.requestRender();
|
|
1988
|
+
void controlDownload(config, jobId, "cancel").catch(() => {});
|
|
1989
|
+
finish("cancelled");
|
|
1990
|
+
}
|
|
1991
|
+
},
|
|
1992
|
+
};
|
|
1993
|
+
});
|
|
1994
|
+
return outcome;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/** Live server log viewer: subscribes to ws://host:{wsPort}/logs/stream, shows
|
|
1998
|
+
* the snapshot backlog then live entries; Esc closes. */
|
|
1999
|
+
async function liveLogsView(ctx: UiCtx, config: LemonadeConfig): Promise<void> {
|
|
2000
|
+
ctx.ui.setStatus("lemonade-setup", "Finding websocket port…");
|
|
2001
|
+
const health = await fetchHealth(config);
|
|
2002
|
+
ctx.ui.setStatus("lemonade-setup", undefined);
|
|
2003
|
+
const wsPort = health?.websocket_port;
|
|
2004
|
+
if (!wsPort) {
|
|
2005
|
+
ctx.ui.notify("Server unreachable, or no websocket port advertised — cannot stream logs.", "error");
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
let host = "localhost";
|
|
2009
|
+
try {
|
|
2010
|
+
host = new URL(config.baseUrl).hostname;
|
|
2011
|
+
} catch { /* keep localhost */ }
|
|
2012
|
+
const wsUrl = `ws://${host}:${wsPort}/logs/stream`;
|
|
2013
|
+
|
|
2014
|
+
await ctx.ui.custom<null>((tui, theme, _kb, done) => {
|
|
2015
|
+
const container = new Container();
|
|
2016
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
2017
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(`Live Server Logs`)), 1, 0));
|
|
2018
|
+
container.addChild(new Text(theme.fg("dim", wsUrl), 0, 0));
|
|
2019
|
+
const logText = new Text("connecting…", 0, 0);
|
|
2020
|
+
container.addChild(logText);
|
|
2021
|
+
container.addChild(new Text(theme.fg("dim", "esc to close • newest 40 lines shown"), 1, 0));
|
|
2022
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
2023
|
+
|
|
2024
|
+
const lines: string[] = [];
|
|
2025
|
+
let closed = false;
|
|
2026
|
+
const push = (line: string) => {
|
|
2027
|
+
if (closed) return;
|
|
2028
|
+
lines.push(line.slice(0, 200));
|
|
2029
|
+
if (lines.length > 5000) lines.splice(0, lines.length - 5000);
|
|
2030
|
+
logText.setText(lines.slice(-40).join("\n"));
|
|
2031
|
+
tui.requestRender();
|
|
2032
|
+
};
|
|
2033
|
+
|
|
2034
|
+
let ws: { close: () => void } | null = null;
|
|
2035
|
+
try {
|
|
2036
|
+
const WS = (globalThis as { WebSocket?: new (url: string) => WebSocket }).WebSocket;
|
|
2037
|
+
if (!WS) throw new Error("no WebSocket global in this runtime");
|
|
2038
|
+
const sock = new WS(wsUrl) as WebSocket & {
|
|
2039
|
+
onopen: (() => void) | null;
|
|
2040
|
+
onmessage: ((ev: { data: unknown }) => void) | null;
|
|
2041
|
+
onclose: (() => void) | null;
|
|
2042
|
+
onerror: (() => void) | null;
|
|
2043
|
+
send: (data: string) => void;
|
|
2044
|
+
};
|
|
2045
|
+
ws = { close: () => { try { sock.close(); } catch { /* ignore */ } } };
|
|
2046
|
+
sock.onopen = () => {
|
|
2047
|
+
sock.send(JSON.stringify({ type: "logs.subscribe", after_seq: null }));
|
|
2048
|
+
push("— subscribed —");
|
|
2049
|
+
};
|
|
2050
|
+
sock.onmessage = (ev) => {
|
|
2051
|
+
try {
|
|
2052
|
+
const msg = JSON.parse(String(ev.data)) as {
|
|
2053
|
+
type?: string;
|
|
2054
|
+
entries?: Array<{ timestamp?: string; line?: string }>;
|
|
2055
|
+
entry?: { timestamp?: string; line?: string };
|
|
2056
|
+
};
|
|
2057
|
+
if (msg.type === "logs.snapshot") {
|
|
2058
|
+
for (const e of msg.entries ?? []) push(`${e.timestamp ?? ""} ${e.line ?? ""}`);
|
|
2059
|
+
if (!(msg.entries ?? []).length) push("(no backlog entries)");
|
|
2060
|
+
} else if (msg.type === "logs.entry") {
|
|
2061
|
+
const e = msg.entry ?? {};
|
|
2062
|
+
push(`${e.timestamp ?? ""} ${e.line ?? ""}`);
|
|
2063
|
+
}
|
|
2064
|
+
} catch { /* non-JSON frame */ }
|
|
2065
|
+
};
|
|
2066
|
+
sock.onclose = () => push("— connection closed —");
|
|
2067
|
+
sock.onerror = () => push("— connection error —");
|
|
2068
|
+
} catch (error) {
|
|
2069
|
+
push(`connect failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
return {
|
|
2073
|
+
render: (w) => container.render(w),
|
|
2074
|
+
invalidate: () => container.invalidate(),
|
|
2075
|
+
handleInput: (data) => {
|
|
2076
|
+
if (matchesKey(data, Key.escape)) {
|
|
2077
|
+
closed = true;
|
|
2078
|
+
ws?.close();
|
|
2079
|
+
done(null);
|
|
2080
|
+
tui.requestRender();
|
|
2081
|
+
}
|
|
2082
|
+
},
|
|
2083
|
+
};
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/** Format a metric family section title, e.g. "── /v1/stats ────────────". */
|
|
2088
|
+
function sectionTitle(name: string): string {
|
|
2089
|
+
const label = ` ${name} `;
|
|
2090
|
+
return `──${label}${"─".repeat(Math.max(2, 44 - label.length))}`;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
/** Compact token counts: 15,231,250 → "15.2M", 243,182 stays locale-grouped. */
|
|
2094
|
+
function fmtTokens(n: number): string {
|
|
2095
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
|
|
2096
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
2097
|
+
return Math.round(n).toLocaleString("en-US");
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/** Byte counts → human-readable storage sizes. */
|
|
2101
|
+
function fmtBytes(n: number): string {
|
|
2102
|
+
if (n >= 1e12) return `${(n / 1e12).toFixed(1)} TB`;
|
|
2103
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(1)} GB`;
|
|
2104
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MB`;
|
|
2105
|
+
return `${Math.round(n / 1e3)} kB`;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/** Parse "125.07 GB"-style totals from system-info into a GiB number. */
|
|
2109
|
+
function parseGbTotal(s?: string): number | null {
|
|
2110
|
+
if (!s) return null;
|
|
2111
|
+
const m = s.match(/([\d.]+)\s*GB/i);
|
|
2112
|
+
return m ? parseFloat(m[1]) : null;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* Full status panel: every metric family the server exposes, grouped under
|
|
2117
|
+
* a title naming the endpoint it came from. Each section degrades to an
|
|
2118
|
+
* "(unreachable)" line when its endpoint fails — one dead endpoint never
|
|
2119
|
+
* blanks the rest of the panel.
|
|
2120
|
+
*/
|
|
2121
|
+
function formatStatus(
|
|
2122
|
+
config: LemonadeConfig,
|
|
2123
|
+
health: LemonadeHealth | null,
|
|
2124
|
+
sys: SystemStats | null,
|
|
2125
|
+
perf: PerfStats | null,
|
|
2126
|
+
sysinfo: SystemInfo | null,
|
|
2127
|
+
metrics: Map<string, number> | null,
|
|
2128
|
+
updated?: Date
|
|
2129
|
+
): string[] {
|
|
2130
|
+
if (!health) {
|
|
2131
|
+
return [
|
|
2132
|
+
`Unreachable: ${url(config, config.healthPath)}`,
|
|
2133
|
+
"",
|
|
2134
|
+
"Check that the lemonade server is running, then verify the baseUrl",
|
|
2135
|
+
"under Server settings (or run Discover servers).",
|
|
2136
|
+
];
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
const lines: string[] = [];
|
|
2140
|
+
const telemetry = health.telemetry?.enabled
|
|
2141
|
+
? `on${health.telemetry.captures?.length ? ` (${health.telemetry.captures.join(", ")})` : ""}`
|
|
2142
|
+
: "off";
|
|
2143
|
+
|
|
2144
|
+
// Header
|
|
2145
|
+
lines.push(
|
|
2146
|
+
`Status: ${health.status ?? "?"} Lemonade v${health.version ?? "?"} Telemetry: ${telemetry}` +
|
|
2147
|
+
(updated ? ` · updated ${updated.toTimeString().slice(0, 8)}` : ""),
|
|
2148
|
+
`Endpoint: ${config.baseUrl} WebSocket port: ${health.websocket_port ?? "?"}`,
|
|
2149
|
+
""
|
|
2150
|
+
);
|
|
2151
|
+
|
|
2152
|
+
// ── /v1/health ───────────────────────────────────────────────────────
|
|
2153
|
+
lines.push(sectionTitle(config.healthPath));
|
|
2154
|
+
lines.push(`Loaded models (${health.all_models_loaded?.length ?? 0}):`);
|
|
2155
|
+
for (const m of health.all_models_loaded ?? []) {
|
|
2156
|
+
lines.push(
|
|
2157
|
+
` • ${m.model_name} — ${m.type}, ${m.status}, ${m.device ?? "?"}${m.pinned ? ", pinned" : ""}` +
|
|
2158
|
+
`, ctx ${m.max_context_window ?? "?"}`
|
|
2159
|
+
);
|
|
2160
|
+
const live: string[] = [];
|
|
2161
|
+
live.push(m.is_busy ? "busy" : "idle");
|
|
2162
|
+
if (m.is_streaming) live.push("streaming");
|
|
2163
|
+
live.push(
|
|
2164
|
+
`backend ${m.backend_health ?? "?"}${m.backend_alive === false ? " (backend not responding!)" : ""}`
|
|
2165
|
+
);
|
|
2166
|
+
if (m.watchdog_reset) live.push("watchdog reset");
|
|
2167
|
+
lines.push(` ${live.join(" ")}`);
|
|
2168
|
+
const meta: string[] = [];
|
|
2169
|
+
if (m.recipe) meta.push(m.recipe);
|
|
2170
|
+
if (m.checkpoint) meta.push(m.checkpoint);
|
|
2171
|
+
if (m.slot_pool) meta.push(`pool ${m.slot_pool}`);
|
|
2172
|
+
if (m.residency_class) meta.push(m.residency_class);
|
|
2173
|
+
if (typeof m.pid === "number") meta.push(`pid ${m.pid}`);
|
|
2174
|
+
if (typeof m.last_use === "number") meta.push(`last use ${m.last_use}`);
|
|
2175
|
+
if (meta.length) lines.push(` ${meta.join(" ")}`);
|
|
2176
|
+
}
|
|
2177
|
+
if (!health.all_models_loaded?.length) lines.push(" (none)");
|
|
2178
|
+
const slots = Object.entries(health.max_models ?? {})
|
|
2179
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
2180
|
+
.join(" ");
|
|
2181
|
+
if (slots) lines.push(`Slot limits: ${slots}`);
|
|
2182
|
+
const pinned = Object.entries(health.pinned_models ?? {})
|
|
2183
|
+
.filter(([, v]) => v > 0)
|
|
2184
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
2185
|
+
.join(" ");
|
|
2186
|
+
const helpers = Object.values(health.pinned_helper_models ?? {}).reduce((a, b) => a + b, 0);
|
|
2187
|
+
if (pinned) lines.push(`Pinned: ${pinned}${helpers ? ` (+${helpers} pinned helper${helpers > 1 ? "s" : ""})` : ""}`);
|
|
2188
|
+
lines.push("");
|
|
2189
|
+
|
|
2190
|
+
// ── /v1/system-stats ─────────────────────────────────────────────────
|
|
2191
|
+
lines.push(sectionTitle("/v1/system-stats"));
|
|
2192
|
+
if (sys) {
|
|
2193
|
+
const pct = (v?: number | null) => (typeof v === "number" ? `${v.toFixed(0)}%` : "?");
|
|
2194
|
+
const gb = (v?: number | null) => (typeof v === "number" ? `${v.toFixed(1)} GB` : "?");
|
|
2195
|
+
const totalRam = parseGbTotal(sysinfo?.["Physical Memory"]);
|
|
2196
|
+
const ramPart =
|
|
2197
|
+
typeof sys.memory_gb === "number"
|
|
2198
|
+
? totalRam
|
|
2199
|
+
? `RAM ${sys.memory_gb.toFixed(1)}/${totalRam.toFixed(1)} GB (${((sys.memory_gb / totalRam) * 100).toFixed(0)}%)`
|
|
2200
|
+
: `RAM ${sys.memory_gb.toFixed(1)} GB`
|
|
2201
|
+
: "RAM ?";
|
|
2202
|
+
lines.push(
|
|
2203
|
+
`CPU ${pct(sys.cpu_percent)} ${ramPart} GPU ${pct(sys.gpu_percent)} VRAM ${gb(sys.vram_gb)}` +
|
|
2204
|
+
(sys.npu_percent !== undefined && sys.npu_percent !== null ? ` NPU ${pct(sys.npu_percent)}` : "")
|
|
2205
|
+
);
|
|
2206
|
+
} else {
|
|
2207
|
+
lines.push(" (unreachable)");
|
|
2208
|
+
}
|
|
2209
|
+
lines.push("");
|
|
2210
|
+
|
|
2211
|
+
// ── /v1/stats ─────────────────────────────────────────────────────────
|
|
2212
|
+
lines.push(sectionTitle("/v1/stats"));
|
|
2213
|
+
if (perf) {
|
|
2214
|
+
if (typeof perf.tokens_per_second === "number") {
|
|
2215
|
+
const num = (v: number | null | undefined, digits = 0) =>
|
|
2216
|
+
typeof v === "number" ? v.toLocaleString("en-US", { maximumFractionDigits: digits }) : "?";
|
|
2217
|
+
lines.push(
|
|
2218
|
+
`Last request: ${perf.tokens_per_second.toFixed(1)} tok/s, TTFT ${
|
|
2219
|
+
typeof perf.time_to_first_token === "number" ? perf.time_to_first_token.toFixed(2) : "?"
|
|
2220
|
+
}s (in ${num(perf.input_tokens)} / out ${num(perf.output_tokens)} tokens)`
|
|
2221
|
+
);
|
|
2222
|
+
if (typeof perf.prompt_tokens === "number") {
|
|
2223
|
+
const cachePart =
|
|
2224
|
+
typeof perf.cache_tokens === "number"
|
|
2225
|
+
? perf.prompt_tokens > 0
|
|
2226
|
+
? `, ${num(perf.cache_tokens)} from prefix cache (${((perf.cache_tokens / perf.prompt_tokens) * 100).toFixed(1)}%)`
|
|
2227
|
+
: `, ${num(perf.cache_tokens)} from prefix cache`
|
|
2228
|
+
: "";
|
|
2229
|
+
lines.push(` prompt ${num(perf.prompt_tokens)} tokens${cachePart}`);
|
|
2230
|
+
}
|
|
2231
|
+
const lifetime: string[] = [];
|
|
2232
|
+
if (typeof perf.request_count_total === "number") lifetime.push(`${num(perf.request_count_total)} requests`);
|
|
2233
|
+
if (typeof perf.input_tokens_total === "number") lifetime.push(`in ${fmtTokens(perf.input_tokens_total)}`);
|
|
2234
|
+
if (typeof perf.output_tokens_total === "number") lifetime.push(`out ${fmtTokens(perf.output_tokens_total)}`);
|
|
2235
|
+
if (typeof perf.prompt_tokens_total === "number") lifetime.push(`prompt ${fmtTokens(perf.prompt_tokens_total)}`);
|
|
2236
|
+
if (typeof perf.cache_tokens_total === "number") lifetime.push(`cache ${fmtTokens(perf.cache_tokens_total)}`);
|
|
2237
|
+
if (lifetime.length) lines.push(`Lifetime: ${lifetime.join(" · ")}`);
|
|
2238
|
+
if (typeof perf.routing_decisions_total === "number" || typeof perf.routing_switches_total === "number") {
|
|
2239
|
+
lines.push(
|
|
2240
|
+
`Routing: ${num(perf.routing_decisions_total)} decisions, ${num(perf.routing_switches_total)} switches`
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
} else {
|
|
2244
|
+
lines.push("No requests served since server start.");
|
|
2245
|
+
}
|
|
2246
|
+
} else {
|
|
2247
|
+
lines.push(" (unreachable)");
|
|
2248
|
+
}
|
|
2249
|
+
lines.push("");
|
|
2250
|
+
|
|
2251
|
+
// ── /v1/system-info ──────────────────────────────────────────────────
|
|
2252
|
+
lines.push(sectionTitle("/v1/system-info"));
|
|
2253
|
+
if (sysinfo) {
|
|
2254
|
+
const cpu = sysinfo.devices?.cpu;
|
|
2255
|
+
if (sysinfo.Processor || cpu?.cores) {
|
|
2256
|
+
const coresPart = cpu?.cores ? ` (${cpu.cores}c/${cpu.threads ?? "?"}t)` : "";
|
|
2257
|
+
lines.push(`CPU: ${sysinfo.Processor ?? cpu?.name ?? "?"}${coresPart}`);
|
|
2258
|
+
}
|
|
2259
|
+
if (sysinfo["OS Version"]) lines.push(`OS: ${sysinfo["OS Version"]}`);
|
|
2260
|
+
const gpu = sysinfo.devices?.amd_gpu?.find((g) => g.available !== false) ?? sysinfo.devices?.nvidia_gpu?.find((g) => g.available !== false);
|
|
2261
|
+
if (gpu) {
|
|
2262
|
+
// On Linux the amd_gpu "name" can be a raw device id (e.g. "110501");
|
|
2263
|
+
// fall back to the family in that case.
|
|
2264
|
+
const gpuName = gpu.name && !/^\d+$/.test(gpu.name) ? gpu.name : gpu.family || "?";
|
|
2265
|
+
const gpuParts: string[] = [];
|
|
2266
|
+
if (typeof gpu.integrated === "boolean") gpuParts.push(gpu.integrated ? "integrated" : "discrete");
|
|
2267
|
+
if (gpu.family && gpuName !== gpu.family) gpuParts.push(gpu.family);
|
|
2268
|
+
if (typeof gpu.virtual_mem_gb === "number") gpuParts.push(`virtual mem ${gpu.virtual_mem_gb.toFixed(1)} GB`);
|
|
2269
|
+
else if (typeof gpu.vram_gb === "number" && gpu.vram_gb > 0) gpuParts.push(`VRAM ${gpu.vram_gb.toFixed(1)} GB`);
|
|
2270
|
+
lines.push(`GPU: ${gpuName}${gpuParts.length ? ` — ${gpuParts.join(", ")}` : ""}`);
|
|
2271
|
+
}
|
|
2272
|
+
const npu = sysinfo.devices?.amd_npu;
|
|
2273
|
+
if (npu && npu.available !== false) {
|
|
2274
|
+
const npuParts: string[] = [];
|
|
2275
|
+
if (npu.power_mode) npuParts.push(`power mode ${npu.power_mode}`);
|
|
2276
|
+
if (typeof npu.tops_max_int === "number") npuParts.push(`${npu.tops_max_int} TOPS`);
|
|
2277
|
+
if (npuParts.length || npu.name || npu.family) {
|
|
2278
|
+
lines.push(`NPU: ${npu.name || "?"}${npu.family ? ` (${npu.family})` : ""}${npuParts.length ? ` — ${npuParts.join(", ")}` : ""}`);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
if (sysinfo["Windows Power Setting"]) lines.push(`Power plan: ${sysinfo["Windows Power Setting"]}`);
|
|
2282
|
+
const store = sysinfo.model_storage;
|
|
2283
|
+
if (store && typeof store.total_bytes === "number") {
|
|
2284
|
+
const free = typeof store.free_bytes === "number" ? ` (${fmtBytes(store.free_bytes)} free)` : "";
|
|
2285
|
+
lines.push(
|
|
2286
|
+
`Model storage: ${fmtBytes(store.used_bytes ?? 0)} used / ${fmtBytes(store.total_bytes)} total${free}`
|
|
2287
|
+
);
|
|
2288
|
+
if (store.path) lines.push(` ${store.path}`);
|
|
2289
|
+
}
|
|
2290
|
+
} else {
|
|
2291
|
+
lines.push(" (unreachable)");
|
|
2292
|
+
}
|
|
2293
|
+
lines.push("");
|
|
2294
|
+
|
|
2295
|
+
// ── /metrics ──────────────────────────────────────────────────────────
|
|
2296
|
+
lines.push(sectionTitle("/metrics (llama.cpp backend)"));
|
|
2297
|
+
if (metrics && metrics.size) {
|
|
2298
|
+
const proc = metrics.get("lemonade_llamacpp_requests_processing");
|
|
2299
|
+
const deferred = metrics.get("lemonade_llamacpp_requests_deferred");
|
|
2300
|
+
const busy = metrics.get("lemonade_llamacpp_n_busy_slots_per_decode");
|
|
2301
|
+
const peak = metrics.get("lemonade_llamacpp_n_tokens_max");
|
|
2302
|
+
if (proc === undefined && deferred === undefined && peak === undefined) {
|
|
2303
|
+
lines.push(" (no llamacpp backend metrics — no llama.cpp model loaded)");
|
|
2304
|
+
} else {
|
|
2305
|
+
lines.push(
|
|
2306
|
+
`Queue: ${proc ?? 0} processing, ${deferred ?? 0} deferred` +
|
|
2307
|
+
(busy !== undefined ? ` Busy slots/decode: ${busy.toFixed(2)}` : "")
|
|
2308
|
+
);
|
|
2309
|
+
if (peak !== undefined) lines.push(`Peak sequence: ${fmtTokens(peak)} tokens (largest prompt+generation observed)`);
|
|
2310
|
+
}
|
|
2311
|
+
} else {
|
|
2312
|
+
lines.push(" (unreachable)");
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
return lines;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
/** Fetch all five status endpoints and format the panel, timestamped "now". */
|
|
2319
|
+
async function fetchStatusLines(config: LemonadeConfig): Promise<string[]> {
|
|
2320
|
+
const [h, sys, perf, info, metricsBody] = await Promise.all([
|
|
2321
|
+
fetchHealth(config),
|
|
2322
|
+
fetchJsonOrNull<SystemStats>(config, "/v1/system-stats"),
|
|
2323
|
+
fetchJsonOrNull<PerfStats>(config, "/v1/stats"),
|
|
2324
|
+
fetchJsonOrNull<SystemInfo>(config, "/v1/system-info"),
|
|
2325
|
+
fetchTextOrNull(config, "/metrics"),
|
|
2326
|
+
]);
|
|
2327
|
+
const metrics = metricsBody ? parsePrometheus(metricsBody) : null;
|
|
2328
|
+
return formatStatus(config, h, sys, perf, info, metrics, new Date());
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
/** Run an async op with a status indicator. Returns [true, result] or
|
|
2332
|
+
* [false, error message] — a discriminated tuple, so callers can narrow. */
|
|
2333
|
+
async function withStatus<T>(
|
|
2334
|
+
ctx: UiCtx,
|
|
2335
|
+
label: string,
|
|
2336
|
+
op: () => Promise<T>
|
|
2337
|
+
): Promise<[true, T] | [false, string]> {
|
|
2338
|
+
ctx.ui.setStatus("lemonade-setup", label);
|
|
2339
|
+
try {
|
|
2340
|
+
return [true, await op()];
|
|
2341
|
+
} catch (error) {
|
|
2342
|
+
return [false, error instanceof Error ? error.message : String(error)];
|
|
2343
|
+
} finally {
|
|
2344
|
+
ctx.ui.setStatus("lemonade-setup", undefined);
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
/**
|
|
2349
|
+
* Pull a model with live progress: server-owned download job + progress view.
|
|
2350
|
+
* Falls back to a blocking pull on servers without job support.
|
|
2351
|
+
*/
|
|
2352
|
+
async function pullWithProgress(
|
|
2353
|
+
ctx: UiCtx,
|
|
2354
|
+
pi: ExtensionAPI,
|
|
2355
|
+
config: LemonadeConfig,
|
|
2356
|
+
id: string,
|
|
2357
|
+
extra?: Record<string, unknown>
|
|
2358
|
+
): Promise<[boolean, string]> {
|
|
2359
|
+
let job: DownloadJob | null = null;
|
|
2360
|
+
try {
|
|
2361
|
+
job = await startPullJob(config, id, extra);
|
|
2362
|
+
} catch {
|
|
2363
|
+
// Fallback: blocking pull (older server without download jobs)
|
|
2364
|
+
return withStatus(ctx, `Pulling ${id} (blocking)…`, () => pullModelBlocking(config, id));
|
|
2365
|
+
}
|
|
2366
|
+
const outcome = await trackDownload(ctx, config, job.id, id);
|
|
2367
|
+
if (outcome === "complete") {
|
|
2368
|
+
await registerLemonadeProvider(pi, config); // model now in catalog
|
|
2369
|
+
return [true, `Pulled ${id}.`];
|
|
2370
|
+
}
|
|
2371
|
+
if (outcome === "cancelled") {
|
|
2372
|
+
return [false, `Pull of ${id} cancelled.`];
|
|
2373
|
+
}
|
|
2374
|
+
return [false, `Pull of ${id} failed.`];
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/** Search Hugging Face via the lemonade server and install a model as user.* */
|
|
2378
|
+
async function installFromHuggingFace(ctx: UiCtx, pi: ExtensionAPI, config: LemonadeConfig): Promise<void> {
|
|
2379
|
+
const query = (await ctx.ui.input("Search Hugging Face (GGUF repos):", ""))?.trim();
|
|
2380
|
+
if (!query || query.length < 3) {
|
|
2381
|
+
if (query) ctx.ui.notify("Search query must be at least 3 characters.", "warning");
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
ctx.ui.setStatus("lemonade-setup", `Searching Hugging Face for "${query}"…`);
|
|
2386
|
+
const res = await fetch(
|
|
2387
|
+
url(config, `${config.registrySearchPath}?query=${encodeURIComponent(query)}&format=gguf&limit=12`),
|
|
2388
|
+
{ headers: authHeaders(config), signal: AbortSignal.timeout(config.discoveryTimeoutMs * 3) }
|
|
2389
|
+
);
|
|
2390
|
+
ctx.ui.setStatus("lemonade-setup", undefined);
|
|
2391
|
+
if (!res.ok) {
|
|
2392
|
+
ctx.ui.notify(`Registry search failed (HTTP ${res.status}).`, "error");
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
const payload = (await res.json()) as { results?: RegistryResult[] };
|
|
2396
|
+
const results = payload.results ?? [];
|
|
2397
|
+
if (!results.length) {
|
|
2398
|
+
ctx.ui.notify("No matching repositories found.", "warning");
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
const repo = await menu(
|
|
2403
|
+
ctx,
|
|
2404
|
+
"Search Results",
|
|
2405
|
+
results.map((r) => ({
|
|
2406
|
+
value: r.repository_id,
|
|
2407
|
+
label: r.repository_id,
|
|
2408
|
+
description: [
|
|
2409
|
+
r.downloads !== undefined ? `${(r.downloads / 1000).toFixed(0)}k downloads` : undefined,
|
|
2410
|
+
r.likes !== undefined ? `${r.likes} likes` : undefined,
|
|
2411
|
+
(r.tags ?? []).slice(0, 3).join(", "),
|
|
2412
|
+
].filter(Boolean).join(" • "),
|
|
2413
|
+
}))
|
|
2414
|
+
);
|
|
2415
|
+
if (!repo) return;
|
|
2416
|
+
|
|
2417
|
+
ctx.ui.setStatus("lemonade-setup", `Inspecting ${repo}…`);
|
|
2418
|
+
const varRes = await fetch(
|
|
2419
|
+
url(config, `${config.pullVariantsPath}?checkpoint=${encodeURIComponent(repo)}`),
|
|
2420
|
+
{ headers: authHeaders(config), signal: AbortSignal.timeout(config.discoveryTimeoutMs * 4) }
|
|
2421
|
+
);
|
|
2422
|
+
ctx.ui.setStatus("lemonade-setup", undefined);
|
|
2423
|
+
if (!varRes.ok) {
|
|
2424
|
+
ctx.ui.notify(`Could not inspect variants (HTTP ${varRes.status}).`, "error");
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
const variants = (await varRes.json()) as PullVariants;
|
|
2428
|
+
if (!variants.variants?.length) {
|
|
2429
|
+
ctx.ui.notify("No installable variants found for this repository.", "warning");
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
const variant = await menu(
|
|
2434
|
+
ctx,
|
|
2435
|
+
`Variants of ${repo}`,
|
|
2436
|
+
variants.variants.map((v) => ({
|
|
2437
|
+
value: v.name,
|
|
2438
|
+
label: v.name,
|
|
2439
|
+
description: `${formatBytes(v.size_bytes)}${v.sharded ? " • sharded" : ""}`,
|
|
2440
|
+
}))
|
|
2441
|
+
);
|
|
2442
|
+
if (!variant) return;
|
|
2443
|
+
const chosen = variants.variants.find((v) => v.name === variant)!;
|
|
2444
|
+
|
|
2445
|
+
const modelName = `user.${variants.suggested_name ?? repo.split("/")[1]}`;
|
|
2446
|
+
const vision = (variants.suggested_labels ?? []).includes("vision");
|
|
2447
|
+
const ok = await ctx.ui.confirm(
|
|
2448
|
+
`Install as ${modelName}?`,
|
|
2449
|
+
`${repo}:${chosen.primary_file} (${formatBytes(chosen.size_bytes)})` +
|
|
2450
|
+
(vision ? " • vision model (mmproj included)" : "")
|
|
2451
|
+
);
|
|
2452
|
+
if (!ok) return;
|
|
2453
|
+
|
|
2454
|
+
const extra: Record<string, unknown> = {
|
|
2455
|
+
checkpoint: `${repo}:${chosen.primary_file}`,
|
|
2456
|
+
recipe: variants.recipe ?? "llamacpp",
|
|
2457
|
+
};
|
|
2458
|
+
if (vision && variants.mmproj_files?.[0]) {
|
|
2459
|
+
extra.mmproj = variants.mmproj_files[0];
|
|
2460
|
+
extra.vision = true;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
const [succeeded, msg] = await pullWithProgress(ctx, pi, config, modelName, extra);
|
|
2464
|
+
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
|
|
2468
|
+
/** Interactive flow to register a new lemonade instance. Returns its name on
|
|
2469
|
+
* success, undefined otherwise. Validates the name, verifies reachability,
|
|
2470
|
+
* persists to config.servers, and registers the new provider immediately. */
|
|
2471
|
+
async function addInstanceFlow(
|
|
2472
|
+
ctx: UiCtx,
|
|
2473
|
+
pi: ExtensionAPI,
|
|
2474
|
+
config: LemonadeConfig,
|
|
2475
|
+
presetUrl?: string
|
|
2476
|
+
): Promise<string | undefined> {
|
|
2477
|
+
const nameInput = (await ctx.ui.input("Instance name (lowercase letters, numbers, hyphens):", ""))?.trim();
|
|
2478
|
+
if (!nameInput) return undefined;
|
|
2479
|
+
if (
|
|
2480
|
+
nameInput === "default" ||
|
|
2481
|
+
config.servers.some((s) => s.name === nameInput) ||
|
|
2482
|
+
!/^[a-z0-9][a-z0-9-]*$/.test(nameInput)
|
|
2483
|
+
) {
|
|
2484
|
+
ctx.ui.notify(
|
|
2485
|
+
'Invalid name — use lowercase letters/numbers/hyphens; "default" and existing instance names are reserved.',
|
|
2486
|
+
"warning"
|
|
2487
|
+
);
|
|
2488
|
+
return undefined;
|
|
2489
|
+
}
|
|
2490
|
+
const urlInput = presetUrl ?? (await ctx.ui.input("Lemonade base URL:", "http://"));
|
|
2491
|
+
if (urlInput === undefined) return undefined; // Esc = cancel, not "invalid URL"
|
|
2492
|
+
const base = normalizeBaseUrl(urlInput.trim());
|
|
2493
|
+
if (!/^https?:\/\/.+/.test(base)) {
|
|
2494
|
+
ctx.ui.notify("Invalid URL.", "warning");
|
|
2495
|
+
return undefined;
|
|
2496
|
+
}
|
|
2497
|
+
const description = (await ctx.ui.input("Description (optional, your own reminder):", ""))?.trim() ?? "";
|
|
2498
|
+
const view: InstanceView = {
|
|
2499
|
+
name: nameInput,
|
|
2500
|
+
description,
|
|
2501
|
+
isDefault: false,
|
|
2502
|
+
config: { ...config, baseUrl: base },
|
|
2503
|
+
};
|
|
2504
|
+
ctx.ui.setStatus("lemonade-setup", `Verifying ${base}…`);
|
|
2505
|
+
const health = await fetchHealth(view.config);
|
|
2506
|
+
ctx.ui.setStatus("lemonade-setup", undefined);
|
|
2507
|
+
if (!health) {
|
|
2508
|
+
const ok = await ctx.ui.confirm(
|
|
2509
|
+
"Server unreachable — add anyway?",
|
|
2510
|
+
`${base} did not respond to a health check. The provider will register with an empty model list until it is reachable.`
|
|
2511
|
+
);
|
|
2512
|
+
if (!ok) return undefined;
|
|
2513
|
+
}
|
|
2514
|
+
config.servers.push({ name: nameInput, baseUrl: base, description: description || undefined });
|
|
2515
|
+
saveConfig(config);
|
|
2516
|
+
const [, msg] = await withStatus(ctx, "Registering providers…", () =>
|
|
2517
|
+
registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered across all instances.`)
|
|
2518
|
+
);
|
|
2519
|
+
void msg;
|
|
2520
|
+
return nameInput;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
|
|
2524
|
+
pi.registerCommand("lemonade-setup", {
|
|
2525
|
+
description: "Lemonade server: status, discovery, endpoints, models, tools, transcription",
|
|
2526
|
+
handler: async (_args, ctx) => {
|
|
2527
|
+
let activeInstance: string | null = null; // resolved at loop top (servers may be empty)
|
|
2528
|
+
for (;;) {
|
|
2529
|
+
const config = configRef.current;
|
|
2530
|
+
|
|
2531
|
+
// ── First-run wizard: no instances yet — nothing to manage. Offer
|
|
2532
|
+
// discovery or manual entry; the normal menu takes over once at
|
|
2533
|
+
// least one instance exists.
|
|
2534
|
+
if (config.servers.length === 0) {
|
|
2535
|
+
const pick = await menu(ctx, "🍋 Lemonade Setup — first run", [
|
|
2536
|
+
{ value: "discover", label: "Discover servers", description: "UDP beacon scan + HTTP fallback — finds lemonade boxes on the LAN" },
|
|
2537
|
+
{ value: "manual", label: "Add instance manually", description: "Pick a name, type the base URL (e.g. http://10.1.1.75:13305)" },
|
|
2538
|
+
{ value: "exit", label: "Exit", description: "Leave empty for now — this wizard reappears next time" },
|
|
2539
|
+
]);
|
|
2540
|
+
if (!pick || pick === "exit") break; // falsy = cancelled/exit — never fall through to discover
|
|
2541
|
+
if (pick === "manual") {
|
|
2542
|
+
await addInstanceFlow(ctx, pi, config);
|
|
2543
|
+
} else {
|
|
2544
|
+
ctx.ui.setStatus("lemonade-setup", `Scanning UDP beacons (${config.beaconTimeoutMs / 1000}s)…`);
|
|
2545
|
+
let servers = await discoverViaBeacon(config, config.beaconTimeoutMs);
|
|
2546
|
+
ctx.ui.setStatus("lemonade-setup", undefined);
|
|
2547
|
+
if (!servers.length) {
|
|
2548
|
+
ctx.ui.setStatus("lemonade-setup", "No beacons — probing known hosts/ports…");
|
|
2549
|
+
servers = await discoverViaHttp(config);
|
|
2550
|
+
ctx.ui.setStatus("lemonade-setup", undefined);
|
|
2551
|
+
}
|
|
2552
|
+
if (!servers.length) {
|
|
2553
|
+
ctx.ui.notify(
|
|
2554
|
+
"No lemonade servers found. Note: UDP broadcasts don't reach into WSL2 — add the instance manually, " +
|
|
2555
|
+
"or run discovery from the Windows side.",
|
|
2556
|
+
"warning"
|
|
2557
|
+
);
|
|
2558
|
+
continue;
|
|
2559
|
+
}
|
|
2560
|
+
const picked = await menu(
|
|
2561
|
+
ctx,
|
|
2562
|
+
"Discovered Servers",
|
|
2563
|
+
servers.map((s) => ({ value: s.baseUrl, label: `${s.hostname} — ${s.baseUrl}` }))
|
|
2564
|
+
);
|
|
2565
|
+
if (picked) await addInstanceFlow(ctx, pi, config, picked);
|
|
2566
|
+
}
|
|
2567
|
+
continue;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
if (activeInstance === null || !config.servers.some((s) => s.name === activeInstance)) {
|
|
2571
|
+
activeInstance = config.servers[0].name; // servers[0] IS the default
|
|
2572
|
+
}
|
|
2573
|
+
// Config view of the active instance — status/models/logs act on it.
|
|
2574
|
+
const viewOrErr = instanceView(config, activeInstance);
|
|
2575
|
+
if (typeof viewOrErr === "string") {
|
|
2576
|
+
activeInstance = configRef.current.servers[0].name; // instance was removed/renamed mid-session
|
|
2577
|
+
continue;
|
|
2578
|
+
}
|
|
2579
|
+
const iconfig = viewOrErr;
|
|
2580
|
+
const choice = await menu(ctx, "🍋 Lemonade Setup", [
|
|
2581
|
+
{ value: "status", label: "Server status", description: `Live health + host + perf from ${iconfig.baseUrl}` },
|
|
2582
|
+
{ value: "server", label: "Server settings", description: "Discover, base URL, API key, instances, connection test" },
|
|
2583
|
+
{ value: "models", label: "Model management", description: `List, load, unload, pull (progress+cancel), delete, change-ctx, HF install, filter, refresh — active instance: ${activeInstance}` },
|
|
2584
|
+
{ value: "transcription", label: "Transcription settings", description: "Whisper model, paths, smoke test" },
|
|
2585
|
+
{ value: "logs", label: "Live server logs", description: "Stream the server's log over websocket" },
|
|
2586
|
+
{ value: "instance", label: "Switch instance", description: `Current: ${activeInstance} — pick which box status/models/logs act on` },
|
|
2587
|
+
{ value: "exit", label: "Exit", description: "Close the setup menu" },
|
|
2588
|
+
]);
|
|
2589
|
+
if (choice === null || choice === "exit") break;
|
|
2590
|
+
|
|
2591
|
+
// ── Switch instance ─────────────────────────────────────────────
|
|
2592
|
+
if (choice === "instance") {
|
|
2593
|
+
const picked = await menu(
|
|
2594
|
+
ctx,
|
|
2595
|
+
"Active Instance",
|
|
2596
|
+
listInstances(config).map((v) => ({
|
|
2597
|
+
value: v.name,
|
|
2598
|
+
label: `${v.name}${v.isDefault ? " (default)" : ""}`,
|
|
2599
|
+
description: [v.config.baseUrl, v.description].filter(Boolean).join(" — "),
|
|
2600
|
+
}))
|
|
2601
|
+
);
|
|
2602
|
+
if (picked) activeInstance = picked;
|
|
2603
|
+
continue;
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
// ── Live logs ────────────────────────────────────────────────
|
|
2607
|
+
if (choice === "logs") {
|
|
2608
|
+
await liveLogsView(ctx, iconfig);
|
|
2609
|
+
continue;
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// ── Status ────────────────────────────────────────────────────────
|
|
2613
|
+
// Live panel: polls every statusPollMs while open, and n/p cycles
|
|
2614
|
+
// through every configured instance without leaving the view.
|
|
2615
|
+
if (choice === "status") {
|
|
2616
|
+
const instances = config.servers;
|
|
2617
|
+
let idx = Math.max(0, instances.findIndex((s) => s.name === activeInstance));
|
|
2618
|
+
const cfgFor = (): LemonadeConfig => {
|
|
2619
|
+
const view = instanceView(configRef.current, instances[idx]?.name ?? activeInstance);
|
|
2620
|
+
return typeof view === "string" ? iconfig : view; // stale name → keep the menu's view
|
|
2621
|
+
};
|
|
2622
|
+
ctx.ui.setStatus("lemonade-setup", "Contacting server…");
|
|
2623
|
+
const first = await fetchStatusLines(cfgFor());
|
|
2624
|
+
ctx.ui.setStatus("lemonade-setup", undefined);
|
|
2625
|
+
await textView(
|
|
2626
|
+
ctx,
|
|
2627
|
+
`Server Status — ${instances[idx]?.name ?? activeInstance}`,
|
|
2628
|
+
first,
|
|
2629
|
+
true,
|
|
2630
|
+
{
|
|
2631
|
+
pollMs: config.statusPollMs,
|
|
2632
|
+
refetch: config.statusPollMs > 0 ? () => fetchStatusLines(cfgFor()) : undefined,
|
|
2633
|
+
extraHint: instances.length > 1 ? "n/p instance" : undefined,
|
|
2634
|
+
onKey:
|
|
2635
|
+
instances.length > 1
|
|
2636
|
+
? (data, api) => {
|
|
2637
|
+
if (matchesKey(data, "n")) idx = (idx + 1) % instances.length;
|
|
2638
|
+
else if (matchesKey(data, "p")) idx = (idx - 1 + instances.length) % instances.length;
|
|
2639
|
+
else return false;
|
|
2640
|
+
const name = instances[idx]?.name ?? activeInstance;
|
|
2641
|
+
api.setTitle(`Server Status — ${name}`);
|
|
2642
|
+
return true;
|
|
2643
|
+
}
|
|
2644
|
+
: undefined,
|
|
2645
|
+
}
|
|
2646
|
+
);
|
|
2647
|
+
// The panel was the last thing the user looked at — the menu follows it.
|
|
2648
|
+
const lastName = instances[idx]?.name;
|
|
2649
|
+
if (lastName) activeInstance = lastName;
|
|
2650
|
+
continue;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// ── Server settings ──────────────────────────────────────────────
|
|
2654
|
+
if (choice === "server") {
|
|
2655
|
+
for (;;) {
|
|
2656
|
+
const sub = await menu(ctx, "Server Settings", [
|
|
2657
|
+
{ value: "discover", label: "Discover servers", description: "UDP beacon scan + HTTP fallback" },
|
|
2658
|
+
{ value: "instances", label: "Manage instances", description: `${config.servers.length} instance(s) — servers[0] is the default` },
|
|
2659
|
+
{ value: "baseurl", label: "Edit default instance base URL", description: `Current: ${config.servers[0].baseUrl}` },
|
|
2660
|
+
{ value: "apikey", label: "Edit shared API key", description: `Fallback for instances without their own — ${config.apiKey ? "••••• (set)" : "(none)"}` },
|
|
2661
|
+
{ value: "test", label: "Test connection", description: "Ping the health endpoint" },
|
|
2662
|
+
{ value: "back", label: "Back", description: "" },
|
|
2663
|
+
]);
|
|
2664
|
+
if (sub === null || sub === "back") break;
|
|
2665
|
+
|
|
2666
|
+
if (sub === "discover") {
|
|
2667
|
+
ctx.ui.setStatus("lemonade-setup", `Scanning UDP beacons (${config.beaconTimeoutMs / 1000}s)…`);
|
|
2668
|
+
let servers = await discoverViaBeacon(config, config.beaconTimeoutMs);
|
|
2669
|
+
ctx.ui.setStatus("lemonade-setup", undefined);
|
|
2670
|
+
if (!servers.length) {
|
|
2671
|
+
ctx.ui.setStatus("lemonade-setup", "No beacons — probing known hosts/ports…");
|
|
2672
|
+
servers = await discoverViaHttp(config);
|
|
2673
|
+
ctx.ui.setStatus("lemonade-setup", undefined);
|
|
2674
|
+
}
|
|
2675
|
+
if (!servers.length) {
|
|
2676
|
+
ctx.ui.notify(
|
|
2677
|
+
"No lemonade servers found. Note: UDP LAN broadcasts don't reach into WSL2 — " +
|
|
2678
|
+
"set the URL manually, or run discovery on the Windows side.",
|
|
2679
|
+
"warning"
|
|
2680
|
+
);
|
|
2681
|
+
continue;
|
|
2682
|
+
}
|
|
2683
|
+
const picked = await menu(
|
|
2684
|
+
ctx,
|
|
2685
|
+
"Discovered Servers",
|
|
2686
|
+
servers.map((s) => ({
|
|
2687
|
+
value: s.baseUrl,
|
|
2688
|
+
label: `${s.hostname} — ${s.baseUrl}`,
|
|
2689
|
+
}))
|
|
2690
|
+
);
|
|
2691
|
+
if (picked) {
|
|
2692
|
+
if (picked === config.servers[0].baseUrl) {
|
|
2693
|
+
ctx.ui.notify("That is already the default instance.", "info");
|
|
2694
|
+
continue;
|
|
2695
|
+
}
|
|
2696
|
+
const use = await menu(
|
|
2697
|
+
ctx,
|
|
2698
|
+
`Use ${picked}`,
|
|
2699
|
+
[
|
|
2700
|
+
{ value: "default", label: "Set as default instance", description: "Replaces the current default" },
|
|
2701
|
+
{ value: "instance", label: "Add as named instance", description: "Keeps the default; registers as lemonade-<name>" },
|
|
2702
|
+
{ value: "back", label: "Cancel", description: "" },
|
|
2703
|
+
]
|
|
2704
|
+
);
|
|
2705
|
+
if (use === "default") {
|
|
2706
|
+
config.servers[0].baseUrl = picked;
|
|
2707
|
+
saveConfig(config);
|
|
2708
|
+
const [ok, msg] = await withStatus(ctx, "Re-registering providers…", () =>
|
|
2709
|
+
registerLemonadeProvider(pi, config).then((n) => `Connected — ${n} model(s) registered.`)
|
|
2710
|
+
);
|
|
2711
|
+
ctx.ui.notify(msg, ok ? "info" : "error");
|
|
2712
|
+
} else if (use === "instance") {
|
|
2713
|
+
const added = await addInstanceFlow(ctx, pi, config, picked);
|
|
2714
|
+
if (added) ctx.ui.notify(`Instance "${added}" added — provider lemonade-${added} registered.`, "info");
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
} else if (sub === "instances") {
|
|
2718
|
+
for (;;) {
|
|
2719
|
+
const op = await menu(ctx, "Manage Instances", [
|
|
2720
|
+
{ value: "list", label: "List instances", description: "Names, URLs, descriptions, live reachability" },
|
|
2721
|
+
{ value: "edit", label: "Edit instance", description: "Rename, re-describe, re-point, or re-key an instance" },
|
|
2722
|
+
{ value: "add", label: "Add instance", description: "Register another lemonade box as lemonade-<name>" },
|
|
2723
|
+
...(config.servers.length > 1
|
|
2724
|
+
? [
|
|
2725
|
+
{ value: "makedefault", label: "Make default", description: "Move an instance to the front — servers[0] is the default" },
|
|
2726
|
+
{ value: "remove", label: "Remove instance", description: "Unregister an instance (the last one cannot be removed)" },
|
|
2727
|
+
]
|
|
2728
|
+
: []),
|
|
2729
|
+
{ value: "back", label: "Back", description: "" },
|
|
2730
|
+
]);
|
|
2731
|
+
if (op === null || op === "back") break;
|
|
2732
|
+
|
|
2733
|
+
if (op === "list") {
|
|
2734
|
+
const lines: string[] = [];
|
|
2735
|
+
for (const v of listInstances(config)) {
|
|
2736
|
+
const h = await fetchHealth(v.config);
|
|
2737
|
+
lines.push(
|
|
2738
|
+
`${v.name}${v.isDefault ? " (default)" : ""} — ${v.config.baseUrl} — ` +
|
|
2739
|
+
(h ? `ok (v${h.version ?? "?"}, ${h.all_models_loaded?.length ?? 0} loaded)` : "unreachable")
|
|
2740
|
+
);
|
|
2741
|
+
if (v.description) lines.push(` ${v.description}`);
|
|
2742
|
+
}
|
|
2743
|
+
await textView(ctx, "Instances", lines, true);
|
|
2744
|
+
} else if (op === "edit") {
|
|
2745
|
+
const views = listInstances(config);
|
|
2746
|
+
const target = await menu(
|
|
2747
|
+
ctx,
|
|
2748
|
+
"Edit Instance",
|
|
2749
|
+
views.map((v) => ({
|
|
2750
|
+
value: v.name,
|
|
2751
|
+
label: `${v.name}${v.isDefault ? " (default)" : ""}`,
|
|
2752
|
+
description: [v.config.baseUrl, v.description].filter(Boolean).join(" — "),
|
|
2753
|
+
}))
|
|
2754
|
+
);
|
|
2755
|
+
if (!target) continue;
|
|
2756
|
+
const view = views.find((v) => v.name === target)!;
|
|
2757
|
+
const entry = config.servers.find((x) => x.name === target)!;
|
|
2758
|
+
|
|
2759
|
+
for (;;) {
|
|
2760
|
+
const field = await menu(ctx, `Edit ${target}`, [
|
|
2761
|
+
{ value: "description", label: "Edit description", description: view.description ? `Current: ${view.description.slice(0, 60)}` : "(none set)" },
|
|
2762
|
+
{ value: "name", label: "Edit name", description: `Current: ${view.name}` },
|
|
2763
|
+
{ value: "url", label: "Edit base URL", description: `Current: ${entry.baseUrl}` },
|
|
2764
|
+
{ value: "apikey", label: "Edit API key", description: entry.apiKey ? "Current: ••••• (own key)" : "Current: (inherits the shared key)" },
|
|
2765
|
+
{ value: "back", label: "Back", description: "" },
|
|
2766
|
+
]);
|
|
2767
|
+
if (field === null || field === "back") break;
|
|
2768
|
+
|
|
2769
|
+
if (field === "description") {
|
|
2770
|
+
const input = await ctx.ui.input(
|
|
2771
|
+
"Description (your own reminder's sake, shown in this menu):",
|
|
2772
|
+
view.description
|
|
2773
|
+
);
|
|
2774
|
+
if (input !== undefined) {
|
|
2775
|
+
entry.description = input.trim() || undefined;
|
|
2776
|
+
saveConfig(config);
|
|
2777
|
+
ctx.ui.notify("Description saved.", "info");
|
|
2778
|
+
}
|
|
2779
|
+
} else if (field === "name") {
|
|
2780
|
+
const input = (await ctx.ui.input("New name (lowercase letters, numbers, hyphens):", view.name))?.trim();
|
|
2781
|
+
if (!input || input === view.name) continue;
|
|
2782
|
+
const others = [
|
|
2783
|
+
"default",
|
|
2784
|
+
...config.servers.filter((x) => x.name !== target).map((x) => x.name),
|
|
2785
|
+
];
|
|
2786
|
+
if (others.includes(input) || !/^[a-z0-9][a-z0-9-]*$/.test(input)) {
|
|
2787
|
+
ctx.ui.notify("Invalid or duplicate name.", "warning");
|
|
2788
|
+
continue;
|
|
2789
|
+
}
|
|
2790
|
+
// Uniform ids: renaming changes the provider id too —
|
|
2791
|
+
// unregister the old one before re-registering.
|
|
2792
|
+
try {
|
|
2793
|
+
pi.unregisterProvider(`lemonade-${target}`);
|
|
2794
|
+
} catch { /* not registered */ }
|
|
2795
|
+
entry.name = input;
|
|
2796
|
+
if (activeInstance === target) activeInstance = input;
|
|
2797
|
+
saveConfig(config);
|
|
2798
|
+
const [, msg] = await withStatus(ctx, "Re-registering providers…", () =>
|
|
2799
|
+
registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered.`)
|
|
2800
|
+
);
|
|
2801
|
+
void msg;
|
|
2802
|
+
ctx.ui.notify(`Renamed to "${input}".`, "info");
|
|
2803
|
+
break; // menu keys changed; leave the edit loop
|
|
2804
|
+
} else if (field === "url") {
|
|
2805
|
+
const input = (await ctx.ui.input("New base URL:", entry.baseUrl))?.trim();
|
|
2806
|
+
const base = normalizeBaseUrl(input ?? "");
|
|
2807
|
+
if (!/^https?:\/\//.test(base)) {
|
|
2808
|
+
ctx.ui.notify("Invalid URL.", "warning");
|
|
2809
|
+
continue;
|
|
2810
|
+
}
|
|
2811
|
+
entry.baseUrl = base;
|
|
2812
|
+
saveConfig(config);
|
|
2813
|
+
const [, msg] = await withStatus(ctx, "Re-registering providers…", () =>
|
|
2814
|
+
registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered.`)
|
|
2815
|
+
);
|
|
2816
|
+
void msg;
|
|
2817
|
+
ctx.ui.notify("URL saved.", "info");
|
|
2818
|
+
} else if (field === "apikey") {
|
|
2819
|
+
const input = (await ctx.ui.input("Instance API key (empty = inherit the shared key):", entry.apiKey ?? ""))?.trim();
|
|
2820
|
+
if (input !== undefined) {
|
|
2821
|
+
entry.apiKey = input || undefined;
|
|
2822
|
+
saveConfig(config);
|
|
2823
|
+
const [, msg] = await withStatus(ctx, "Re-registering providers…", () =>
|
|
2824
|
+
registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered.`)
|
|
2825
|
+
);
|
|
2826
|
+
void msg;
|
|
2827
|
+
ctx.ui.notify(input ? "API key saved." : "API key cleared — instance inherits the shared key.", "info");
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
} else if (op === "add") {
|
|
2832
|
+
const added = await addInstanceFlow(ctx, pi, config);
|
|
2833
|
+
if (added) ctx.ui.notify(`Instance "${added}" added — provider lemonade-${added} registered.`, "info");
|
|
2834
|
+
} else if (op === "remove") {
|
|
2835
|
+
const victim = await menu(
|
|
2836
|
+
ctx,
|
|
2837
|
+
"Remove Instance",
|
|
2838
|
+
config.servers.map((s) => ({
|
|
2839
|
+
value: s.name,
|
|
2840
|
+
label: `${s.name}${s.name === config.servers[0].name ? " (default)" : ""}`,
|
|
2841
|
+
description: s.baseUrl,
|
|
2842
|
+
}))
|
|
2843
|
+
);
|
|
2844
|
+
if (!victim) continue;
|
|
2845
|
+
const wasDefault = victim === config.servers[0].name;
|
|
2846
|
+
const ok = await ctx.ui.confirm(
|
|
2847
|
+
`Remove instance ${victim}?`,
|
|
2848
|
+
`Unregisters the lemonade-${victim} provider. Files on that box are untouched.` +
|
|
2849
|
+
(wasDefault ? " It is the DEFAULT — the next instance in the list becomes the default." : "")
|
|
2850
|
+
);
|
|
2851
|
+
if (!ok) continue;
|
|
2852
|
+
config.servers = config.servers.filter((x) => x.name !== victim);
|
|
2853
|
+
saveConfig(config);
|
|
2854
|
+
if (activeInstance === victim) activeInstance = config.servers[0].name;
|
|
2855
|
+
try {
|
|
2856
|
+
pi.unregisterProvider(`lemonade-${victim}`);
|
|
2857
|
+
} catch { /* not registered */ }
|
|
2858
|
+
const [rok, rmsg] = await withStatus(ctx, "Re-registering providers…", () =>
|
|
2859
|
+
registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered.`)
|
|
2860
|
+
);
|
|
2861
|
+
ctx.ui.notify(rmsg, rok ? "info" : "error");
|
|
2862
|
+
} else if (op === "makedefault") {
|
|
2863
|
+
const pick = await menu(
|
|
2864
|
+
ctx,
|
|
2865
|
+
"Make Default",
|
|
2866
|
+
config.servers.slice(1).map((s) => ({
|
|
2867
|
+
value: s.name,
|
|
2868
|
+
label: s.name,
|
|
2869
|
+
description: [s.baseUrl, s.description].filter(Boolean).join(" — "),
|
|
2870
|
+
}))
|
|
2871
|
+
);
|
|
2872
|
+
if (!pick) continue;
|
|
2873
|
+
const idx = config.servers.findIndex((x) => x.name === pick);
|
|
2874
|
+
const [entry] = config.servers.splice(idx, 1);
|
|
2875
|
+
config.servers.unshift(entry);
|
|
2876
|
+
saveConfig(config);
|
|
2877
|
+
// Provider ids are lemonade-<name>, unchanged by reordering —
|
|
2878
|
+
// no re-registration needed; only defaultness moved.
|
|
2879
|
+
ctx.ui.notify(`"${pick}" is now the default instance (servers[0]).`, "info");
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
} else if (sub === "baseurl") {
|
|
2883
|
+
const input = await ctx.ui.input("Default instance base URL:", config.servers[0].baseUrl);
|
|
2884
|
+
if (input !== undefined && input.trim()) {
|
|
2885
|
+
config.servers[0].baseUrl = normalizeBaseUrl(input);
|
|
2886
|
+
saveConfig(config);
|
|
2887
|
+
const [ok, msg] = await withStatus(ctx, "Re-registering provider…", () =>
|
|
2888
|
+
registerLemonadeProvider(pi, config).then((n) => `Saved — ${n} model(s) registered.`)
|
|
2889
|
+
);
|
|
2890
|
+
ctx.ui.notify(msg, ok ? "info" : "error");
|
|
2891
|
+
}
|
|
2892
|
+
} else if (sub === "apikey") {
|
|
2893
|
+
const input = await ctx.ui.input("Shared API key (instances without their own inherit it; empty for none):", config.apiKey);
|
|
2894
|
+
if (input !== undefined) {
|
|
2895
|
+
config.apiKey = input.trim();
|
|
2896
|
+
saveConfig(config);
|
|
2897
|
+
ctx.ui.notify("API key saved.", "info");
|
|
2898
|
+
}
|
|
2899
|
+
} else if (sub === "test") {
|
|
2900
|
+
const h = await fetchHealth(iconfig);
|
|
2901
|
+
ctx.ui.notify(
|
|
2902
|
+
h
|
|
2903
|
+
? `OK — ${h.status ?? "?"} v${h.version ?? "?"}, ${h.all_models_loaded?.length ?? 0} model(s) loaded`
|
|
2904
|
+
: `Failed to reach ${iconfig.baseUrl}`,
|
|
2905
|
+
h ? "info" : "error"
|
|
2906
|
+
);
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
continue;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// ── Model management ──────────────────────────────────────────────
|
|
2913
|
+
if (choice === "models") {
|
|
2914
|
+
for (;;) {
|
|
2915
|
+
const sub = await menu(ctx, "Model Management", [
|
|
2916
|
+
{ value: "list", label: "List catalog", description: "All advertised models + live state" },
|
|
2917
|
+
{ value: "load", label: "Load a model", description: "Load into memory (may take minutes)" },
|
|
2918
|
+
{ value: "unload", label: "Unload a model", description: "Free its slot" },
|
|
2919
|
+
{ value: "pull", label: "Pull (download) a model", description: "Live progress, esc to cancel" },
|
|
2920
|
+
{ value: "installhf", label: "Install from Hugging Face", description: "Search HF, pick a variant, install as user.*" },
|
|
2921
|
+
{ value: "delete", label: "Delete a model", description: "Typed-phrase confirmation required" },
|
|
2922
|
+
{ value: "changectx", label: "Change context size", description: "Unload → reload with new ctx (saved)" },
|
|
2923
|
+
{
|
|
2924
|
+
value: "chatonly",
|
|
2925
|
+
label: "Toggle chat-only filter",
|
|
2926
|
+
description: `Currently: ${config.chatOnly ? "chat models only" : "all models"}`,
|
|
2927
|
+
},
|
|
2928
|
+
{ value: "refresh", label: "Refresh provider", description: "Re-discover and re-register in /model" },
|
|
2929
|
+
{ value: "back", label: "Back", description: "" },
|
|
2930
|
+
]);
|
|
2931
|
+
if (sub === null || sub === "back") break;
|
|
2932
|
+
|
|
2933
|
+
if (sub === "installhf") {
|
|
2934
|
+
await installFromHuggingFace(ctx, pi, iconfig);
|
|
2935
|
+
continue;
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
if (sub === "chatonly") {
|
|
2939
|
+
config.chatOnly = !config.chatOnly;
|
|
2940
|
+
saveConfig(config);
|
|
2941
|
+
const [ok, msg] = await withStatus(ctx, "Re-registering…", () =>
|
|
2942
|
+
registerLemonadeProvider(pi, config).then((n) => `Filter ${config.chatOnly ? "on" : "off"} — ${n} model(s) registered.`)
|
|
2943
|
+
);
|
|
2944
|
+
ctx.ui.notify(msg, ok ? "info" : "error");
|
|
2945
|
+
continue;
|
|
2946
|
+
}
|
|
2947
|
+
if (sub === "refresh") {
|
|
2948
|
+
const [ok, msg] = await withStatus(ctx, "Refreshing…", () =>
|
|
2949
|
+
registerLemonadeProvider(pi, config).then((n) => `Provider refreshed — ${n} model(s) registered.`)
|
|
2950
|
+
);
|
|
2951
|
+
ctx.ui.notify(msg, ok ? "info" : "error");
|
|
2952
|
+
continue;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
// Everything else needs the live catalog. show_all=true includes
|
|
2956
|
+
// not-yet-downloaded registry entries (pull candidates).
|
|
2957
|
+
let catalog: LemonadeModel[];
|
|
2958
|
+
let loadedIds = new Set<string>();
|
|
2959
|
+
let loadedCtx = new Map<string, number>();
|
|
2960
|
+
try {
|
|
2961
|
+
const health = await fetchHealth(iconfig);
|
|
2962
|
+
loadedIds = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
|
|
2963
|
+
for (const m of health?.all_models_loaded ?? []) {
|
|
2964
|
+
if (m.max_context_window) loadedCtx.set(m.model_name, m.max_context_window);
|
|
2965
|
+
}
|
|
2966
|
+
catalog = await fetchCatalog(iconfig, true);
|
|
2967
|
+
} catch (error) {
|
|
2968
|
+
ctx.ui.notify(`Could not reach server: ${error instanceof Error ? error.message : error}`, "error");
|
|
2969
|
+
continue;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
if (sub === "list") {
|
|
2973
|
+
const lines: string[] = [`Catalog: ${catalog.length} model(s), ${loadedIds.size} loaded`, ""];
|
|
2974
|
+
for (const m of catalog) {
|
|
2975
|
+
const state = loadedIds.has(m.id) ? "● loaded" : "○ on-demand";
|
|
2976
|
+
lines.push(
|
|
2977
|
+
`${state} ${m.id}` +
|
|
2978
|
+
(m.labels?.length ? ` [${m.labels.join(", ")}]` : "") +
|
|
2979
|
+
(m.downloaded === false ? " (not downloaded)" : "") +
|
|
2980
|
+
(m.max_context_window ? ` ctx ${m.max_context_window}` : "") +
|
|
2981
|
+
(m.size ? ` ${m.size} GB` : "")
|
|
2982
|
+
);
|
|
2983
|
+
}
|
|
2984
|
+
await textView(ctx, "Model Catalog", lines, true);
|
|
2985
|
+
continue;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
if (sub === "changectx") {
|
|
2989
|
+
if (!loadedIds.size) {
|
|
2990
|
+
ctx.ui.notify("Nothing is loaded — load a model first.", "warning");
|
|
2991
|
+
continue;
|
|
2992
|
+
}
|
|
2993
|
+
const picked = await menu(ctx, "Change Context Size (pick model)", [...loadedIds].map((id) => ({
|
|
2994
|
+
value: id,
|
|
2995
|
+
label: id,
|
|
2996
|
+
})));
|
|
2997
|
+
if (!picked) continue;
|
|
2998
|
+
const currentCtx =
|
|
2999
|
+
loadedCtx.get(picked) ??
|
|
3000
|
+
catalog.find((m) => m.id === picked)?.max_context_window ??
|
|
3001
|
+
32768;
|
|
3002
|
+
const input = await ctx.ui.input(
|
|
3003
|
+
`New context size (current: ${currentCtx}; e.g. 32k, 1m, or raw tokens):`,
|
|
3004
|
+
String(currentCtx)
|
|
3005
|
+
);
|
|
3006
|
+
const ctxSize = input ? parseCtxSize(input) : null;
|
|
3007
|
+
if (!ctxSize) {
|
|
3008
|
+
ctx.ui.notify("Invalid size — use e.g. 32k, 1m, or 262144.", "warning");
|
|
3009
|
+
continue;
|
|
3010
|
+
}
|
|
3011
|
+
const ok = await ctx.ui.confirm(
|
|
3012
|
+
"Unload and reload?",
|
|
3013
|
+
`${picked} will be unloaded and reloaded with ctx ${ctxSize} (saved for future loads).`
|
|
3014
|
+
);
|
|
3015
|
+
if (!ok) continue;
|
|
3016
|
+
const [succeeded, msg] = await withStatus(ctx, `Reloading ${picked} with ctx ${ctxSize}…`, () =>
|
|
3017
|
+
changeModelContext(iconfig, picked, ctxSize)
|
|
3018
|
+
);
|
|
3019
|
+
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
3020
|
+
if (succeeded) await registerLemonadeProvider(pi, config);
|
|
3021
|
+
continue;
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
if (sub === "pull" || sub === "delete") {
|
|
3025
|
+
const predicate = sub === "delete"
|
|
3026
|
+
? (m: LemonadeModel) => m.downloaded !== false
|
|
3027
|
+
: (m: LemonadeModel) => m.downloaded === false;
|
|
3028
|
+
const candidates = catalog.filter(predicate);
|
|
3029
|
+
|
|
3030
|
+
let picked: string | null = null;
|
|
3031
|
+
if (candidates.length) {
|
|
3032
|
+
picked = await menu(
|
|
3033
|
+
ctx,
|
|
3034
|
+
sub === "pull" ? "Pull (download)" : "Delete from disk",
|
|
3035
|
+
candidates.map((m) => ({
|
|
3036
|
+
value: m.id,
|
|
3037
|
+
label: m.id,
|
|
3038
|
+
description: [
|
|
3039
|
+
loadedIds.has(m.id) ? "loaded" : undefined,
|
|
3040
|
+
m.size ? `${m.size} GB` : undefined,
|
|
3041
|
+
m.recipe,
|
|
3042
|
+
].filter(Boolean).join(" • "),
|
|
3043
|
+
}))
|
|
3044
|
+
);
|
|
3045
|
+
} else {
|
|
3046
|
+
const input = await ctx.ui.input(
|
|
3047
|
+
sub === "pull"
|
|
3048
|
+
? "No undownloaded models in catalog. Enter a model id to pull:"
|
|
3049
|
+
: "Enter a model id to delete:",
|
|
3050
|
+
""
|
|
3051
|
+
);
|
|
3052
|
+
picked = input?.trim() || null;
|
|
3053
|
+
}
|
|
3054
|
+
if (!picked) continue;
|
|
3055
|
+
|
|
3056
|
+
if (sub === "pull") {
|
|
3057
|
+
const size = catalog.find((m) => m.id === picked)?.size;
|
|
3058
|
+
const ok = await ctx.ui.confirm(
|
|
3059
|
+
`Pull ${picked}?`,
|
|
3060
|
+
size ? `Downloads ~${size} GB to the server's disk.` : "Downloads the model to the server's disk."
|
|
3061
|
+
);
|
|
3062
|
+
if (!ok) continue;
|
|
3063
|
+
const [succeeded, msg] = await pullWithProgress(ctx, pi, iconfig, picked!);
|
|
3064
|
+
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
3065
|
+
continue;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
// delete: confirm + typed phrase, 3 attempts
|
|
3069
|
+
const ok = await ctx.ui.confirm(
|
|
3070
|
+
`Delete ${picked}?`,
|
|
3071
|
+
"This permanently removes the model files from the server's disk. Load state and cache are also lost."
|
|
3072
|
+
);
|
|
3073
|
+
if (!ok) continue;
|
|
3074
|
+
const phrase = `i want to delete ${picked.toLowerCase()}`;
|
|
3075
|
+
let confirmed = false;
|
|
3076
|
+
for (let attempt = 1; attempt <= 3 && !confirmed; attempt++) {
|
|
3077
|
+
const chancesLeft = 4 - attempt;
|
|
3078
|
+
const typed = await ctx.ui.input(
|
|
3079
|
+
`Type "${phrase}" (${chancesLeft} chance${chancesLeft === 1 ? "" : "s"} left):`,
|
|
3080
|
+
""
|
|
3081
|
+
);
|
|
3082
|
+
if (typed === undefined) break; // Esc = abort immediately
|
|
3083
|
+
if (typed.trim().toLowerCase().replace(/\s+/g, " ") === phrase) {
|
|
3084
|
+
confirmed = true;
|
|
3085
|
+
} else if (attempt < 3) {
|
|
3086
|
+
ctx.ui.notify("Phrase did not match.", "warning");
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
if (!confirmed) {
|
|
3090
|
+
ctx.ui.notify(`Deletion of ${picked} aborted.`, "warning");
|
|
3091
|
+
continue;
|
|
3092
|
+
}
|
|
3093
|
+
const [succeeded, msg] = await withStatus(ctx, `Deleting ${picked}…`, () =>
|
|
3094
|
+
deleteModel(iconfig, picked!)
|
|
3095
|
+
);
|
|
3096
|
+
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
3097
|
+
if (succeeded) await registerLemonadeProvider(pi, config);
|
|
3098
|
+
continue;
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
// load / unload
|
|
3102
|
+
{
|
|
3103
|
+
const candidates =
|
|
3104
|
+
sub === "unload"
|
|
3105
|
+
? [...loadedIds]
|
|
3106
|
+
: catalog.map((m) => m.id).filter((id) => !loadedIds.has(id));
|
|
3107
|
+
if (!candidates.length) {
|
|
3108
|
+
ctx.ui.notify(sub === "unload" ? "Nothing is loaded." : "Everything is already loaded.", "warning");
|
|
3109
|
+
continue;
|
|
3110
|
+
}
|
|
3111
|
+
const picked = await menu(
|
|
3112
|
+
ctx,
|
|
3113
|
+
sub === "load" ? "Load model" : "Unload model",
|
|
3114
|
+
candidates.map((id) => ({
|
|
3115
|
+
value: id,
|
|
3116
|
+
label: id,
|
|
3117
|
+
description: loadedIds.has(id) ? "loaded" : "on-demand",
|
|
3118
|
+
}))
|
|
3119
|
+
);
|
|
3120
|
+
if (!picked) continue;
|
|
3121
|
+
const [succeeded, msg] = await withStatus(ctx, `${sub === "load" ? "Loading" : "Unloading"} ${picked}…`, () =>
|
|
3122
|
+
sub === "load" ? loadModel(iconfig, picked) : unloadModel(iconfig, picked).then(() => `Unloaded ${picked}.`)
|
|
3123
|
+
);
|
|
3124
|
+
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
3125
|
+
if (succeeded) await registerLemonadeProvider(pi, config);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
continue;
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
// ── Transcription settings ────────────────────────────────────────
|
|
3132
|
+
if (choice === "transcription") {
|
|
3133
|
+
for (;;) {
|
|
3134
|
+
const sub = await menu(ctx, "Transcription Settings", [
|
|
3135
|
+
{
|
|
3136
|
+
value: "model",
|
|
3137
|
+
label: "Default transcription model",
|
|
3138
|
+
description: `Current: ${config.defaultTranscriptionModel}`,
|
|
3139
|
+
},
|
|
3140
|
+
{
|
|
3141
|
+
value: "transpath",
|
|
3142
|
+
label: "Transcription endpoint path",
|
|
3143
|
+
description: `Current: ${config.transcriptionPath}`,
|
|
3144
|
+
},
|
|
3145
|
+
{ value: "test", label: "Smoke test", description: "Transcribe a file of your choosing" },
|
|
3146
|
+
{ value: "back", label: "Back", description: "" },
|
|
3147
|
+
]);
|
|
3148
|
+
if (sub === null || sub === "back") break;
|
|
3149
|
+
|
|
3150
|
+
if (sub === "model" || sub === "transpath") {
|
|
3151
|
+
const input = await ctx.ui.input(
|
|
3152
|
+
sub === "model" ? "Transcription model id:" : "Transcription endpoint path:",
|
|
3153
|
+
sub === "model" ? config.defaultTranscriptionModel : config.transcriptionPath
|
|
3154
|
+
);
|
|
3155
|
+
if (input !== undefined && input.trim()) {
|
|
3156
|
+
if (sub === "model") config.defaultTranscriptionModel = input.trim();
|
|
3157
|
+
else config.transcriptionPath = input.trim();
|
|
3158
|
+
saveConfig(config);
|
|
3159
|
+
ctx.ui.notify("Saved.", "info");
|
|
3160
|
+
}
|
|
3161
|
+
} else if (sub === "test") {
|
|
3162
|
+
const file = await ctx.ui.input("Path to an audio/video file:", "");
|
|
3163
|
+
if (!file?.trim()) continue;
|
|
3164
|
+
const resolved = path.resolve(file.trim());
|
|
3165
|
+
// Same code path as the transcribe_audio tool (transcribeViaServer),
|
|
3166
|
+
// acting on the menu's active instance.
|
|
3167
|
+
const [succeeded, result] = await withStatus(ctx, `Transcribing ${path.basename(resolved)}…`, () =>
|
|
3168
|
+
transcribeViaServer(iconfig, resolved)
|
|
3169
|
+
);
|
|
3170
|
+
if (!succeeded) {
|
|
3171
|
+
ctx.ui.notify(result, "error");
|
|
3172
|
+
} else {
|
|
3173
|
+
await textView(
|
|
3174
|
+
ctx,
|
|
3175
|
+
"Transcription Result",
|
|
3176
|
+
[`File: ${resolved}`, `Model: ${result.model}`, "", result.text || "(empty)"],
|
|
3177
|
+
true
|
|
3178
|
+
);
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
},
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
// ─── Extension entry ────────────────────────────────────────────────────────
|
|
3189
|
+
|
|
3190
|
+
export default async function piLemonadeLink(pi: ExtensionAPI): Promise<void> {
|
|
3191
|
+
// The config file is REQUIRED — nothing is hardcoded, not even a default
|
|
3192
|
+
// server, because a config-less run would silently target a server that
|
|
3193
|
+
// isn't yours. On failure we do two things, belt and braces: register a
|
|
3194
|
+
// session_start handler that shows a red error banner in the chat window,
|
|
3195
|
+
// and throw so pi logs the extension load failure. (The throw is in case
|
|
3196
|
+
// pi drops handlers registered by an extension that fails during load;
|
|
3197
|
+
// if it keeps them, the user sees the reason twice — loudly, both times.)
|
|
3198
|
+
let config: LemonadeConfig;
|
|
3199
|
+
try {
|
|
3200
|
+
ensureConfigFile(); // first run: create a blank starter config instead of blocking
|
|
3201
|
+
config = loadConfig();
|
|
3202
|
+
} catch (err) {
|
|
3203
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3204
|
+
const failure = [
|
|
3205
|
+
"pi-lemonade-link did NOT load — no lemonade providers, tools, or /lemonade-setup are registered.",
|
|
3206
|
+
"",
|
|
3207
|
+
`Reason: ${reason}`,
|
|
3208
|
+
"",
|
|
3209
|
+
`Everything (base URL, endpoints, defaults) is config-driven from ${CONFIG_PATH} — nothing is hardcoded.`,
|
|
3210
|
+
"To set it up, copy the fully-commented example into your pi agent dir and edit it for your server:",
|
|
3211
|
+
"",
|
|
3212
|
+
" cp ~/.pi/agent/extensions/pi-lemonade-link/lemonade.example.json ~/.pi/agent/lemonade.json",
|
|
3213
|
+
"",
|
|
3214
|
+
'A minimal config is enough to start: { "servers": [{ "name": "main", "baseUrl": "http://your-lemonade-server:13305" }] }',
|
|
3215
|
+
].join("\n");
|
|
3216
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
3217
|
+
if (ctx.hasUI) ctx.ui.notify(failure, "error");
|
|
3218
|
+
});
|
|
3219
|
+
throw new Error(failure);
|
|
3220
|
+
}
|
|
3221
|
+
const configRef = { current: config };
|
|
3222
|
+
// No startup save: the config file is user-authored and may carry JSONC
|
|
3223
|
+
// comments, and rewriting it here would strip them. Missing keys are
|
|
3224
|
+
// merged from DEFAULT_CONFIG in memory; /lemonade-setup persists the full
|
|
3225
|
+
// normalized object when the user actually changes something.
|
|
3226
|
+
|
|
3227
|
+
await registerLemonadeProvider(pi, configRef.current);
|
|
3228
|
+
await registerAgentTools(pi, configRef.current);
|
|
3229
|
+
registerLemonadeBar(pi, configRef);
|
|
3230
|
+
registerSetupCommand(pi, configRef);
|
|
3231
|
+
|
|
3232
|
+
// Fresh install (or a hand-emptied config): nothing is registered yet —
|
|
3233
|
+
// point the user at the wizard instead of leaving them wondering why
|
|
3234
|
+
// /model has no lemonade entries.
|
|
3235
|
+
if (configRef.current.servers.length === 0) {
|
|
3236
|
+
pi.on("session_start", (_event, ctx) => {
|
|
3237
|
+
if (ctx.hasUI)
|
|
3238
|
+
ctx.ui.notify(
|
|
3239
|
+
"pi-lemonade-link: no servers configured yet — run /lemonade-setup to discover or add your lemonade box " +
|
|
3240
|
+
"(or edit ~/.pi/agent/lemonade.json and /reload).",
|
|
3241
|
+
"info"
|
|
3242
|
+
);
|
|
3243
|
+
});
|
|
3244
|
+
}
|
|
3245
|
+
}
|