pi-llama-cpp 0.12.0 → 0.14.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 +20 -9
- package/package.json +3 -3
- package/src/api/client.ts +25 -0
- package/src/constants.ts +8 -5
- package/src/enums/status.ts +0 -1
- package/src/interfaces/endpoints/models.ts +1 -1
- package/src/interfaces/settings.ts +3 -2
- package/src/interfaces/sortBy.ts +4 -0
- package/src/managers/command/models.ts +247 -0
- package/src/managers/command.ts +29 -459
- package/src/managers/events.ts +2 -2
- package/src/managers/server.ts +29 -5
- package/src/managers/settings.ts +12 -88
- package/src/models/baseModel.ts +27 -10
- package/src/models/legacyModel.ts +2 -2
- package/src/models/routerModel.ts +2 -2
- package/src/models/singleModel.ts +1 -1
- package/src/server.ts +35 -48
- package/src/sse/client.ts +113 -59
- package/src/sse/fetch.ts +43 -0
- package/src/sse/manager.ts +9 -27
- package/src/sse/types.ts +0 -4
- package/src/ui/dialog/base.ts +118 -0
- package/src/ui/dialog/confirm.ts +45 -0
- package/src/ui/dialog/factory.ts +111 -0
- package/src/ui/dialog/input.ts +63 -0
- package/src/ui/dialog/options.ts +23 -0
- package/src/ui/editors/editorOptions.ts +43 -0
- package/src/ui/editors/itemBuilder.ts +47 -0
- package/src/ui/editors/listEditor.ts +291 -0
- package/src/ui/editors/override/entry.ts +24 -0
- package/src/ui/editors/override/entryEditor.ts +166 -0
- package/src/ui/editors/override/fields.ts +294 -0
- package/src/ui/editors/override/handlers.ts +118 -0
- package/src/ui/editors/override/itemBuilder.ts +42 -0
- package/src/ui/editors/override/overrideList.ts +127 -0
- package/src/ui/editors/server/builder.ts +60 -0
- package/src/ui/editors/server/fields.ts +84 -0
- package/src/ui/editors/server/handlers.ts +32 -0
- package/src/ui/editors/server/itemBuilder.ts +68 -0
- package/src/ui/editors/server/serverEditor.ts +161 -0
- package/src/ui/editors/server/utils.ts +45 -0
- package/src/ui/editors/server/wizard.ts +110 -0
- package/src/ui/editors/settingField.ts +37 -0
- package/src/ui/editors/settingsListFactory.ts +33 -0
- package/src/ui/settings/index.ts +237 -0
- package/src/ui/strings.ts +8 -4
- package/src/utils/health.ts +48 -0
- package/src/utils/serverIds.ts +21 -0
- package/src/utils/settingsStore.ts +1 -1
- package/src/utils/urlResolver.ts +129 -0
- package/src/utils/urls.ts +33 -13
- package/tests/commandManager.test.ts +38 -7
- package/tests/dialog.test.ts +95 -2
- package/tests/health.test.ts +116 -0
- package/tests/legacyModel.test.ts +34 -28
- package/tests/overrides.test.ts +123 -68
- package/tests/routerModel.test.ts +67 -68
- package/tests/server.test.ts +47 -16
- package/tests/serverManager.test.ts +4 -4
- package/tests/settings.test.ts +10 -8
- package/tests/singleModel.test.ts +12 -12
- package/tests/sseManager.test.ts +6 -24
- package/src/ui/dialog.ts +0 -287
- package/src/ui/overrideEntryEditor.ts +0 -119
- package/src/ui/overrideSettingsList.ts +0 -682
- package/src/ui/serverListEditor.ts +0 -32
- package/src/ui/serverSettingsList.ts +0 -466
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionUIContext,
|
|
3
|
+
Theme,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type {
|
|
7
|
+
Component,
|
|
8
|
+
Focusable,
|
|
9
|
+
KeybindingsManager,
|
|
10
|
+
SettingItem,
|
|
11
|
+
TUI,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
import { SettingsList } from "@earendil-works/pi-tui";
|
|
14
|
+
import type { LlamaSettings } from "../../interfaces/settings";
|
|
15
|
+
import type { LlamaSettingsManager } from "../../managers/settings";
|
|
16
|
+
|
|
17
|
+
// ─── Options enum ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Identifiers of the editable fields shown in `/models settings`.
|
|
21
|
+
* Values match the scalar `LlamaSettings` keys.
|
|
22
|
+
*/
|
|
23
|
+
export enum Options {
|
|
24
|
+
REACT_TO_MODEL_SELECT = "reactToModelSelect",
|
|
25
|
+
AUTOLOAD_ON_MESSAGE = "autoloadOnMessage",
|
|
26
|
+
SORT_BY = "sortBy",
|
|
27
|
+
POLLING_TIMEOUT = "pollingTimeout",
|
|
28
|
+
SERVER_TIMEOUT = "serverTimeout",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
type SortByValue = NonNullable<LlamaSettings["sortBy"]>;
|
|
34
|
+
|
|
35
|
+
export const SORT_VALUES: SortByValue[] = [
|
|
36
|
+
"asc",
|
|
37
|
+
"desc",
|
|
38
|
+
"asc-name",
|
|
39
|
+
"desc-name",
|
|
40
|
+
"api",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Presets (ms) for `pollingTimeout` */
|
|
44
|
+
export const POLLING_PRESETS = [15000, 30000, 60000, 120000, 300000];
|
|
45
|
+
|
|
46
|
+
/** Presets (ms) for `serverTimeout` */
|
|
47
|
+
export const SERVER_PRESETS = [500, 1000, 2000, 5000, 10000];
|
|
48
|
+
|
|
49
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Formats milliseconds compactly for display (e.g. `500 -> "500ms"`,
|
|
53
|
+
* `60000 -> "60s"`).
|
|
54
|
+
*/
|
|
55
|
+
export const formatMs = (ms: number): string =>
|
|
56
|
+
ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses a value produced by `formatMs()` back to milliseconds.
|
|
60
|
+
* Only ever called with values from the preset lists.
|
|
61
|
+
*/
|
|
62
|
+
const parseMs = (value: string): number =>
|
|
63
|
+
value.endsWith("ms")
|
|
64
|
+
? Number(value.slice(0, -2))
|
|
65
|
+
: Number(value.slice(0, -1)) * 1000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds the `SettingItem[]` for `/models settings` from the current
|
|
69
|
+
* (merged) values of the scalar `llamaSettings` fields.
|
|
70
|
+
*/
|
|
71
|
+
export const buildSettingsItems = async (
|
|
72
|
+
settings: LlamaSettingsManager,
|
|
73
|
+
): Promise<SettingItem[]> => {
|
|
74
|
+
const { pollingTimeout, serverTimeout } = await settings.resolveTimeouts();
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
id: Options.REACT_TO_MODEL_SELECT,
|
|
79
|
+
label: "React to model selection",
|
|
80
|
+
description: "Load the model when you pick it in Pi (immediate)",
|
|
81
|
+
currentValue: (await settings.resolveReactToModelSelect()) ? "on" : "off",
|
|
82
|
+
values: ["on", "off"],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: Options.AUTOLOAD_ON_MESSAGE,
|
|
86
|
+
label: "Autoload on message",
|
|
87
|
+
description:
|
|
88
|
+
"Auto-load the selected model when you send a message (immediate)",
|
|
89
|
+
currentValue: (await settings.resolveAutoloadOnMessage()) ? "on" : "off",
|
|
90
|
+
values: ["on", "off"],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: Options.SORT_BY,
|
|
94
|
+
label: "Sort models by",
|
|
95
|
+
description: "Order of models in /models (next open)",
|
|
96
|
+
currentValue: await settings.resolveSortBy(),
|
|
97
|
+
values: [...SORT_VALUES],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: Options.POLLING_TIMEOUT,
|
|
101
|
+
label: "Polling timeout",
|
|
102
|
+
description: "Max model-load wait (next model load)",
|
|
103
|
+
currentValue: formatMs(pollingTimeout),
|
|
104
|
+
values: POLLING_PRESETS.map(formatMs),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: Options.SERVER_TIMEOUT,
|
|
108
|
+
label: "Server timeout",
|
|
109
|
+
description: "Health check / SSE probe timeout (next model load)",
|
|
110
|
+
currentValue: formatMs(serverTimeout),
|
|
111
|
+
values: SERVER_PRESETS.map(formatMs),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Persists a change made in the settings menu.
|
|
118
|
+
* Maps the `SettingsList` id/value pair to the matching `llamaSettings`
|
|
119
|
+
* key and writes it via `LlamaSettingsManager.setLlamaSetting()`.
|
|
120
|
+
*/
|
|
121
|
+
export const applySettingChange = async (
|
|
122
|
+
id: string,
|
|
123
|
+
newValue: string,
|
|
124
|
+
settings: LlamaSettingsManager,
|
|
125
|
+
): Promise<void> => {
|
|
126
|
+
switch (id) {
|
|
127
|
+
case Options.REACT_TO_MODEL_SELECT:
|
|
128
|
+
await settings.setLlamaSetting("reactToModelSelect", newValue === "on");
|
|
129
|
+
return;
|
|
130
|
+
case Options.AUTOLOAD_ON_MESSAGE:
|
|
131
|
+
await settings.setLlamaSetting("autoloadOnMessage", newValue === "on");
|
|
132
|
+
return;
|
|
133
|
+
case Options.SORT_BY:
|
|
134
|
+
await settings.setLlamaSetting("sortBy", newValue as SortByValue);
|
|
135
|
+
return;
|
|
136
|
+
case Options.POLLING_TIMEOUT:
|
|
137
|
+
await settings.setLlamaSetting("pollingTimeout", parseMs(newValue));
|
|
138
|
+
return;
|
|
139
|
+
case Options.SERVER_TIMEOUT:
|
|
140
|
+
await settings.setLlamaSetting("serverTimeout", parseMs(newValue));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ─── SettingsEditor ─────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/** Options passed to the settings editor. */
|
|
148
|
+
export interface SettingsEditorOptions {
|
|
149
|
+
tui: TUI;
|
|
150
|
+
theme: Theme;
|
|
151
|
+
keybindings: KeybindingsManager;
|
|
152
|
+
settings: LlamaSettingsManager;
|
|
153
|
+
/** Called when Esc closes the editor. */
|
|
154
|
+
done: () => void;
|
|
155
|
+
/** Called on persistence errors. */
|
|
156
|
+
onError: (message: string) => void;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Interactive settings menu for the scalar `llamaSettings` fields.
|
|
161
|
+
* Enter/Space cycles the value under the cursor; Esc closes.
|
|
162
|
+
*
|
|
163
|
+
* Writes go to the global/project `~/.pi/agent/settings.json` via
|
|
164
|
+
* `LlamaSettingsManager.setLlamaSetting()`; write errors are notified
|
|
165
|
+
* and leave the dialog open with values unchanged.
|
|
166
|
+
*/
|
|
167
|
+
export class SettingsEditor implements Component, Focusable {
|
|
168
|
+
private settingsList: SettingsList | null = null;
|
|
169
|
+
private isFocused = false;
|
|
170
|
+
|
|
171
|
+
constructor(private readonly options: SettingsEditorOptions) {
|
|
172
|
+
void this.buildList();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Opens the editor in a modal `ui.custom` dialog and resolves when the
|
|
177
|
+
* user closes it (Esc). Write errors are notified via `ui` and leave
|
|
178
|
+
* the dialog open with values unchanged.
|
|
179
|
+
*/
|
|
180
|
+
static async show(
|
|
181
|
+
ui: ExtensionUIContext,
|
|
182
|
+
settings: LlamaSettingsManager,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
await ui.custom<void>(
|
|
185
|
+
(tui, theme, keybindings, done) =>
|
|
186
|
+
new SettingsEditor({
|
|
187
|
+
tui,
|
|
188
|
+
theme,
|
|
189
|
+
keybindings,
|
|
190
|
+
settings,
|
|
191
|
+
done: () => done(undefined),
|
|
192
|
+
onError: (message) => ui.notify(message, "error"),
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// -- Component -------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
invalidate(): void {
|
|
200
|
+
this.settingsList?.invalidate();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
handleInput(data: string): void {
|
|
204
|
+
this.settingsList?.handleInput(data);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
render(width: number): string[] {
|
|
208
|
+
if (!this.settingsList) return ["Loading..."];
|
|
209
|
+
return this.settingsList.render(width);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// -- Focusable -------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
get focused(): boolean {
|
|
215
|
+
return this.isFocused;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
set focused(value: boolean) {
|
|
219
|
+
this.isFocused = value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// -- internal --------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
private async buildList(): Promise<void> {
|
|
225
|
+
const items = await buildSettingsItems(this.options.settings);
|
|
226
|
+
this.settingsList = new SettingsList(
|
|
227
|
+
items,
|
|
228
|
+
Math.min(items.length + 2, 15),
|
|
229
|
+
getSettingsListTheme(),
|
|
230
|
+
(id, newValue) =>
|
|
231
|
+
applySettingChange(id, newValue, this.options.settings).catch(
|
|
232
|
+
(err: unknown) => this.options.onError(String(err)),
|
|
233
|
+
),
|
|
234
|
+
() => this.options.done(),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/ui/strings.ts
CHANGED
|
@@ -94,10 +94,12 @@ export const FIELDS = {
|
|
|
94
94
|
} as const satisfies Record<string, Field>;
|
|
95
95
|
|
|
96
96
|
/** Dialog prompt for a field: the `label` plus its parenthetical `note`
|
|
97
|
-
* (or an overriding note, e.g. the add-server wizard's "(optional)").
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
* (or an overriding note, e.g. the add-server wizard's "(optional)"). */
|
|
98
|
+
export class FieldMessages {
|
|
99
|
+
static of(field: Field, note = field.note): string {
|
|
100
|
+
return note ? `${field.label} ${note}` : field.label;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
101
103
|
|
|
102
104
|
/**
|
|
103
105
|
* Dialog titles. `edit` composes the shared `Edit <term>` title so the
|
|
@@ -124,4 +126,6 @@ export const HINTS = {
|
|
|
124
126
|
emptyServers: " (a) add server · Esc done",
|
|
125
127
|
/** Hint line when the override entry list is empty */
|
|
126
128
|
emptyOverrideEntries: " (a) add override · Esc back",
|
|
129
|
+
/** Row description for a server in the overrides editor */
|
|
130
|
+
overrideServerRow: "Enter: edit this server's override entries",
|
|
127
131
|
} as const;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ServerStatus } from "../enums/serverStatus";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Probes a llama-server's `/health` endpoint and classifies the outcome.
|
|
5
|
+
*
|
|
6
|
+
* The single source of truth for health classification — both
|
|
7
|
+
* `Server.isReady` and the UI health indicators delegate here.
|
|
8
|
+
*
|
|
9
|
+
* - HTTP success with `{ status: "ok" }` → `READY`
|
|
10
|
+
* - The request is still in flight when the deadline passes
|
|
11
|
+
* (`AbortSignal.timeout` aborts it) → `TIMEOUT`
|
|
12
|
+
* - Anything else (network error, non-JSON or non-ok payload)
|
|
13
|
+
* → `UNREACHABLE`
|
|
14
|
+
*
|
|
15
|
+
* The HTTP status code itself is not inspected: a llama-server answers
|
|
16
|
+
* `/health` with the payload's `status` field, and both classification
|
|
17
|
+
* branches key off it (matching the behavior of `ApiClient.get`, which
|
|
18
|
+
* also ignores status codes and parses the body).
|
|
19
|
+
*
|
|
20
|
+
* @param url The bare origin of the llama-server (the endpoint is
|
|
21
|
+
* `<url>/health`, without the `/v1` prefix)
|
|
22
|
+
* @param timeout Maximum time (ms) to wait for the health check
|
|
23
|
+
* @param apiKey Optional API key, sent as a Bearer token — servers
|
|
24
|
+
* behind an API-key gate would otherwise report UNREACHABLE
|
|
25
|
+
* @returns The classified server status
|
|
26
|
+
*/
|
|
27
|
+
export const checkServerHealth = async (
|
|
28
|
+
url: string,
|
|
29
|
+
timeout: number,
|
|
30
|
+
apiKey?: string,
|
|
31
|
+
): Promise<ServerStatus> => {
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(`${url}/health`, {
|
|
34
|
+
signal: AbortSignal.timeout(timeout),
|
|
35
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
|
|
36
|
+
});
|
|
37
|
+
const data = (await response.json()) as { status?: string };
|
|
38
|
+
return data.status === "ok" ? ServerStatus.READY : ServerStatus.UNREACHABLE;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// `AbortSignal.timeout` rejects `fetch` with a `TimeoutError`
|
|
41
|
+
// DOMException (some runtimes surface it as `AbortError` with a
|
|
42
|
+
// timeout message).
|
|
43
|
+
const isTimeout =
|
|
44
|
+
error instanceof Error &&
|
|
45
|
+
(error.name === "TimeoutError" || error.name === "AbortError");
|
|
46
|
+
return isTimeout ? ServerStatus.TIMEOUT : ServerStatus.UNREACHABLE;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { PROVIDER_PREFIX } from "../constants";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a server's provider ID: the custom ID when set, otherwise the
|
|
5
|
+
* URL-derived ID (`${PROVIDER_PREFIX}=<url>`).
|
|
6
|
+
*
|
|
7
|
+
* Single home for the rule shared by `Server.providerId` (runtime) and
|
|
8
|
+
* `ServerDisplay.suffix` (editor display row), so the ID shown in the
|
|
9
|
+
* editors can't drift from the one used at request time.
|
|
10
|
+
*/
|
|
11
|
+
export class ServerIds {
|
|
12
|
+
/** The URL-derived ID: `<PROVIDER_PREFIX>=<url>`. */
|
|
13
|
+
static fromUrl(url: string): string {
|
|
14
|
+
return `${PROVIDER_PREFIX}=${url}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The effective provider ID: `customId` when set, else URL-derived. */
|
|
18
|
+
static resolve(url: string, customId?: string): string {
|
|
19
|
+
return customId ?? this.fromUrl(url);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -32,7 +32,7 @@ export class SettingsStore {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Serializes (2-space indent) and writes atomically via temp file + rename. */
|
|
35
|
-
async write(root: Record<string, unknown>): Promise<void> {
|
|
35
|
+
private async write(root: Record<string, unknown>): Promise<void> {
|
|
36
36
|
const tmp = `${this.path}.tmp`;
|
|
37
37
|
await writeFile(tmp, JSON.stringify(root, null, 2), "utf-8");
|
|
38
38
|
await rename(tmp, this.path);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { LLAMA_SERVER_URL } from "../constants";
|
|
2
|
+
import type { LlamaSettings } from "../interfaces/settings";
|
|
3
|
+
import { ServerUrl } from "./urls";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Settings readers injected by {@link LlamaSettingsManager}. Both reload
|
|
7
|
+
* settings from disk before reading, so the resolver always sees fresh data.
|
|
8
|
+
*/
|
|
9
|
+
export interface UrlResolverDeps {
|
|
10
|
+
/** Merged `llamaSettings` (project overrides global). */
|
|
11
|
+
getLlamaSettings(): Promise<LlamaSettings>;
|
|
12
|
+
/** Fully merged settings map (used for the legacy `llamaServerUrl` key). */
|
|
13
|
+
getMergedSettings(): Promise<Record<string, any>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Multi-source llama-server URL resolution chain (see REFACTOR.md #4).
|
|
18
|
+
*
|
|
19
|
+
* Resolves URLs in priority order:
|
|
20
|
+
*
|
|
21
|
+
* 1. `LLAMA_SERVER_URL` env variable
|
|
22
|
+
* 2. `llamaSettings.servers[].url` (current — project, then global)
|
|
23
|
+
* 3. `llamaServerUrl` key (legacy — project, then global)
|
|
24
|
+
* 4. Default URL
|
|
25
|
+
*
|
|
26
|
+
* Owns the warnings collected for dropped invalid URL entries; the
|
|
27
|
+
* settings manager surfaces them via `takeWarnings()`.
|
|
28
|
+
*/
|
|
29
|
+
export class UrlResolver {
|
|
30
|
+
/** Warnings collected during URL resolution (dropped invalid entries). */
|
|
31
|
+
private warnings: string[] = [];
|
|
32
|
+
|
|
33
|
+
constructor(private readonly deps: UrlResolverDeps) {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns and clears warnings collected during URL resolution.
|
|
37
|
+
*/
|
|
38
|
+
takeWarnings(): string[] {
|
|
39
|
+
const warnings = [...this.warnings];
|
|
40
|
+
this.warnings.length = 0;
|
|
41
|
+
return warnings;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolves the server URLs to use in the following order:
|
|
46
|
+
*
|
|
47
|
+
* - `LLAMA_SERVER_URL` env variable
|
|
48
|
+
* - `llamaSettings` key (current - project, then global)
|
|
49
|
+
* - `llamaServerUrl` key (legacy - project, then global)
|
|
50
|
+
* - Default URL
|
|
51
|
+
*
|
|
52
|
+
* @returns The list of URLs to use
|
|
53
|
+
*/
|
|
54
|
+
async resolveUrls(): Promise<string[]> {
|
|
55
|
+
let response = this.resolveEnvUrls();
|
|
56
|
+
if (response.length > 0) return response;
|
|
57
|
+
|
|
58
|
+
response = await this.resolveServerUrls();
|
|
59
|
+
if (response.length > 0) return response;
|
|
60
|
+
|
|
61
|
+
response = await this.resolveLegacyUrls();
|
|
62
|
+
if (response.length > 0) return response;
|
|
63
|
+
|
|
64
|
+
return [LLAMA_SERVER_URL];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolves the llama-server URLs from the environment variable.
|
|
69
|
+
*
|
|
70
|
+
* @returns A list of detected URLs
|
|
71
|
+
*/
|
|
72
|
+
private resolveEnvUrls(): string[] {
|
|
73
|
+
const raw = process.env.LLAMA_SERVER_URL;
|
|
74
|
+
if (!raw) return [];
|
|
75
|
+
|
|
76
|
+
return this.parseUrls(raw);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolves the llama-server URLs from `llamaSettings.servers`.
|
|
81
|
+
* Settings are merged, prioritizing project over global settings.
|
|
82
|
+
* Reloads settings from disk before reading.
|
|
83
|
+
*
|
|
84
|
+
* @returns A list of detected URLs
|
|
85
|
+
*/
|
|
86
|
+
private async resolveServerUrls(): Promise<string[]> {
|
|
87
|
+
const { servers = [] } = await this.deps.getLlamaSettings();
|
|
88
|
+
return servers.map((s) => this.parseUrls(s.url)).flat();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolves the llama-server URLs from `llamaServerUrl` legacy key.
|
|
93
|
+
* Settings are merged, prioritizing project over global settings.
|
|
94
|
+
* Reloads settings from disk before reading.
|
|
95
|
+
*
|
|
96
|
+
* @returns A list of detected URLs
|
|
97
|
+
*/
|
|
98
|
+
private async resolveLegacyUrls(): Promise<string[]> {
|
|
99
|
+
const { llamaServerUrl = null } = await this.deps.getMergedSettings();
|
|
100
|
+
if (!llamaServerUrl) return [];
|
|
101
|
+
|
|
102
|
+
return this.parseUrls(llamaServerUrl);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parses a raw URL string into an array of cleaned URLs.
|
|
107
|
+
* Splits on semicolons, trims whitespace, filters empty strings, strips
|
|
108
|
+
* trailing slashes, and drops entries without an http(s) scheme —
|
|
109
|
+
* collecting a warning for each dropped entry (same validation the
|
|
110
|
+
* `/models servers` editor applies).
|
|
111
|
+
*
|
|
112
|
+
* @returns A sanitized URL
|
|
113
|
+
*/
|
|
114
|
+
private parseUrls(raw: string): string[] {
|
|
115
|
+
return raw
|
|
116
|
+
.split(";")
|
|
117
|
+
.map(ServerUrl.normalize)
|
|
118
|
+
.filter((u) => {
|
|
119
|
+
if (u.length === 0) return false;
|
|
120
|
+
if (!ServerUrl.isValid(u)) {
|
|
121
|
+
this.warnings.push(
|
|
122
|
+
`Ignoring invalid server URL '${u}' (needs http(s)://)`,
|
|
123
|
+
);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/utils/urls.ts
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared URL
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Shared server-URL handling: the single place where normalization and
|
|
3
|
+
* validation live, used by the settings parser (`UrlResolver.parseUrls`,
|
|
4
|
+
* see `utils/urlResolver`) and the editor field validator
|
|
5
|
+
* (`ServerFields`) so the two can't drift.
|
|
5
6
|
*/
|
|
6
|
-
export
|
|
7
|
-
|
|
7
|
+
export class ServerUrl {
|
|
8
|
+
/** Trims surrounding whitespace and strips trailing slashes — the
|
|
9
|
+
* same treatment the settings parser applies. */
|
|
10
|
+
static normalize(raw: string): string {
|
|
11
|
+
return raw.trim().replace(/\/+$/, "");
|
|
12
|
+
}
|
|
8
13
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
/** True when the URL carries an http(s) scheme — the only schemes
|
|
15
|
+
* llama-server endpoints use. */
|
|
16
|
+
static isValid(url: string): boolean {
|
|
17
|
+
return /^https?:\/\//i.test(url);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validates and normalizes a user-entered server URL: `normalize`,
|
|
22
|
+
* then rejects empty strings, semicolons (the settings parser splits
|
|
23
|
+
* on them — use separate entries) and values without an http(s) scheme.
|
|
24
|
+
*
|
|
25
|
+
* @returns The normalized URL, or `null` when the input is invalid
|
|
26
|
+
*/
|
|
27
|
+
static parse(raw: string): string | null {
|
|
28
|
+
// Reference the class explicitly (not `this`) so `parse` can be
|
|
29
|
+
// passed around as a bare callback (e.g. as a field validator)
|
|
30
|
+
const url = ServerUrl.normalize(raw);
|
|
31
|
+
if (url.length === 0 || url.includes(";") || !ServerUrl.isValid(url)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return url;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -3,15 +3,16 @@ import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import type { KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
4
4
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { Action } from "../src/enums/action";
|
|
6
|
+
import { CommandManager } from "../src/managers/command";
|
|
7
|
+
import { ServerManager } from "../src/managers/server";
|
|
8
|
+
import type { LlamaSettingsManager } from "../src/managers/settings";
|
|
9
|
+
import { ServerSettingsList } from "../src/ui/editors/server/serverEditor";
|
|
10
|
+
import { ServerDisplay } from "../src/ui/editors/server/utils";
|
|
6
11
|
import {
|
|
7
12
|
applySettingChange,
|
|
8
13
|
buildSettingsItems,
|
|
9
|
-
CommandManager,
|
|
10
14
|
formatMs,
|
|
11
|
-
} from "../src/
|
|
12
|
-
import { ServerManager } from "../src/managers/server";
|
|
13
|
-
import type { LlamaSettingsManager } from "../src/managers/settings";
|
|
14
|
-
import { ServerSettingsList } from "../src/ui/serverSettingsList";
|
|
15
|
+
} from "../src/ui/settings";
|
|
15
16
|
import {
|
|
16
17
|
createMockCtx,
|
|
17
18
|
createMockModel,
|
|
@@ -25,8 +26,27 @@ beforeEach(() => {
|
|
|
25
26
|
initTheme();
|
|
26
27
|
vi.clearAllMocks();
|
|
27
28
|
mockRpc.mockResolvedValue({ data: [] });
|
|
29
|
+
// Mock health checks to return "healthy" immediately
|
|
30
|
+
vi.spyOn(ServerDisplay, "healthEmoji").mockResolvedValue("🟢");
|
|
28
31
|
});
|
|
29
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Waits for a ServerSettingsList to finish its async initialization
|
|
35
|
+
* (health checks + SettingsList construction).
|
|
36
|
+
*/
|
|
37
|
+
const waitForEditor = (editor: ServerSettingsList): Promise<void> =>
|
|
38
|
+
new Promise((resolve) => {
|
|
39
|
+
const check = () => {
|
|
40
|
+
// @ts-expect-error settingsList is private
|
|
41
|
+
if (editor.settingsList) {
|
|
42
|
+
resolve();
|
|
43
|
+
} else {
|
|
44
|
+
setTimeout(check, 10);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
check();
|
|
48
|
+
});
|
|
49
|
+
|
|
30
50
|
describe("CommandManager", () => {
|
|
31
51
|
let serverManager: ServerManager;
|
|
32
52
|
let commandManager: CommandManager;
|
|
@@ -234,7 +254,7 @@ describe("CommandManager", () => {
|
|
|
234
254
|
bold: (text: string) => text,
|
|
235
255
|
}) as unknown as Theme;
|
|
236
256
|
|
|
237
|
-
it("should
|
|
257
|
+
it("should re-register providers after the editor closes", async () => {
|
|
238
258
|
const updateSpy = vi
|
|
239
259
|
.spyOn(serverManager, "update")
|
|
240
260
|
.mockResolvedValue(undefined);
|
|
@@ -242,8 +262,13 @@ describe("CommandManager", () => {
|
|
|
242
262
|
|
|
243
263
|
await commandManager.handleCommand("servers", ctx as any, mockPi as any);
|
|
244
264
|
|
|
245
|
-
expect(updateSpy).not.toHaveBeenCalled();
|
|
246
265
|
expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
|
|
266
|
+
expect(updateSpy).toHaveBeenCalledTimes(1);
|
|
267
|
+
// Re-registration happens on close, not on open. The mock dialog
|
|
268
|
+
// resolves immediately, so the update call must come after custom.
|
|
269
|
+
expect(updateSpy.mock.invocationCallOrder[0]).toBeGreaterThan(
|
|
270
|
+
vi.mocked(ctx.ui.custom).mock.invocationCallOrder[0],
|
|
271
|
+
);
|
|
247
272
|
});
|
|
248
273
|
|
|
249
274
|
it("should notify instead of opening the editor outside the TUI", async () => {
|
|
@@ -289,6 +314,8 @@ describe("CommandManager", () => {
|
|
|
289
314
|
);
|
|
290
315
|
|
|
291
316
|
expect(editor).toBeInstanceOf(ServerSettingsList);
|
|
317
|
+
// Wait for async health checks to complete
|
|
318
|
+
await waitForEditor(editor);
|
|
292
319
|
// Seeded with the merged snapshot
|
|
293
320
|
expect(editor.render(80).join("\n")).toContain("http://seed:1");
|
|
294
321
|
|
|
@@ -302,6 +329,7 @@ describe("CommandManager", () => {
|
|
|
302
329
|
editor.handleInput(ENTER); // skip optional ID
|
|
303
330
|
expect(editor.render(80).join("\n")).toContain("Add server · 3/3");
|
|
304
331
|
editor.handleInput(ENTER); // skip optional name → persist
|
|
332
|
+
// Wait for async persist and rebuild
|
|
305
333
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
306
334
|
expect(editorSettings.setLlamaSetting).toHaveBeenCalledWith("servers", [
|
|
307
335
|
{ url: "http://seed:1" },
|
|
@@ -372,6 +400,9 @@ describe("CommandManager", () => {
|
|
|
372
400
|
vi.fn(),
|
|
373
401
|
);
|
|
374
402
|
|
|
403
|
+
// Wait for async health checks to complete
|
|
404
|
+
await waitForEditor(editor);
|
|
405
|
+
|
|
375
406
|
// Move to the second row and open the confirm dialog
|
|
376
407
|
editor.handleInput("\x1b[B");
|
|
377
408
|
editor.handleInput("d");
|