@aliou/pi-ts-aperture 0.5.1 → 0.6.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 +119 -21
- package/extensions/aperture/dedicated/api-routing.ts +66 -0
- package/extensions/aperture/dedicated/model-defaults.ts +48 -0
- package/extensions/aperture/dedicated/runtime.ts +87 -0
- package/extensions/aperture/index.ts +78 -0
- package/extensions/aperture/onboarding/index.ts +25 -0
- package/extensions/aperture/onboarding/onboarding.ts +892 -0
- package/extensions/aperture/onboarding/setup-command.ts +53 -0
- package/extensions/aperture/onboarding/setup-wizard.ts +134 -0
- package/extensions/aperture/proxy/runtime.ts +160 -0
- package/extensions/aperture/settings-command.ts +369 -0
- package/extensions/aperture/shared/config/defaults.ts +17 -0
- package/extensions/aperture/shared/config/loader.ts +21 -0
- package/extensions/aperture/shared/config/migration/001-legacy-to-v0-6.ts +45 -0
- package/extensions/aperture/shared/config/migration/002-mode-to-capabilities.ts +20 -0
- package/extensions/aperture/shared/config/migration/003-normalize-capabilities.ts +26 -0
- package/extensions/aperture/shared/config/migration/index.ts +15 -0
- package/extensions/aperture/shared/config/types.ts +57 -0
- package/extensions/aperture/shared/sync-bus.ts +12 -0
- package/{src/lib → extensions/aperture/shared}/types.ts +1 -1
- package/package.json +37 -27
- package/src/api/client.ts +139 -0
- package/src/api/types.ts +26 -0
- package/src/provider-mapping.ts +91 -0
- package/src/url.ts +52 -0
- package/src/commands/settings.ts +0 -135
- package/src/commands/setup.ts +0 -232
- package/src/extension/runtime.test.ts +0 -121
- package/src/extension/runtime.ts +0 -144
- package/src/index.ts +0 -97
- package/src/lib/config.ts +0 -32
- package/src/lib/gateway.ts +0 -61
- package/src/lib/url.ts +0 -42
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding wizard for Aperture extension.
|
|
3
|
+
*
|
|
4
|
+
* Steps:
|
|
5
|
+
* 1. Welcome -- explain Aperture and the two modes
|
|
6
|
+
* 2. URL -- input with inline health check
|
|
7
|
+
* 3. Mode -- choose dedicated or proxy
|
|
8
|
+
* 4. Providers -- context-dependent: proxy providers or dedicated gateway providers
|
|
9
|
+
* 5. Recap -- summary before saving
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
getSettingsTheme,
|
|
14
|
+
type SettingsTheme,
|
|
15
|
+
Wizard,
|
|
16
|
+
type WizardStepContext,
|
|
17
|
+
} from "@aliou/pi-utils-settings";
|
|
18
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
19
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
22
|
+
import {
|
|
23
|
+
Box,
|
|
24
|
+
getKeybindings,
|
|
25
|
+
Input,
|
|
26
|
+
Key,
|
|
27
|
+
Markdown,
|
|
28
|
+
matchesKey,
|
|
29
|
+
Text,
|
|
30
|
+
truncateToWidth,
|
|
31
|
+
wrapTextWithAnsi,
|
|
32
|
+
} from "@earendil-works/pi-tui";
|
|
33
|
+
import { ApertureClient } from "../../../src/api/client";
|
|
34
|
+
import {
|
|
35
|
+
mapDedicatedProviders,
|
|
36
|
+
mapProxyProviders,
|
|
37
|
+
} from "../../../src/provider-mapping";
|
|
38
|
+
import type {
|
|
39
|
+
ApertureConfig,
|
|
40
|
+
DedicatedProviderConfig,
|
|
41
|
+
} from "../shared/config/loader";
|
|
42
|
+
import { UrlStep } from "./setup-wizard";
|
|
43
|
+
|
|
44
|
+
// --- Onboarding state ---
|
|
45
|
+
|
|
46
|
+
export interface OnboardingResult {
|
|
47
|
+
completed: boolean;
|
|
48
|
+
baseUrl: string;
|
|
49
|
+
proxyEnabled: boolean;
|
|
50
|
+
dedicatedEnabled: boolean;
|
|
51
|
+
upstreamProviders: { id: string; shouldCheckGatewayModels: boolean }[];
|
|
52
|
+
dedicatedProviders: DedicatedProviderConfig[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface OnboardingState {
|
|
56
|
+
baseUrl: string;
|
|
57
|
+
proxyEnabled: boolean;
|
|
58
|
+
dedicatedEnabled: boolean;
|
|
59
|
+
upstreamProviders: { id: string; shouldCheckGatewayModels: boolean }[];
|
|
60
|
+
dedicatedProviders: DedicatedProviderConfig[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Shared filterable checklist ---
|
|
64
|
+
|
|
65
|
+
const LIST_HEIGHT = 6;
|
|
66
|
+
|
|
67
|
+
interface ChecklistItem {
|
|
68
|
+
id: string;
|
|
69
|
+
label: string;
|
|
70
|
+
checked: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class FilterableChecklist implements Component {
|
|
74
|
+
private readonly searchInput = new Input();
|
|
75
|
+
private selectedIndex = 0;
|
|
76
|
+
private extraHint = "";
|
|
77
|
+
private items: ChecklistItem[];
|
|
78
|
+
private filteredItems: ChecklistItem[];
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
private readonly settingsTheme: SettingsTheme,
|
|
82
|
+
items: ChecklistItem[],
|
|
83
|
+
private readonly onToggle: (id: string) => void,
|
|
84
|
+
/** Optional handler for Ctrl+G (e.g. gateway check toggle). */
|
|
85
|
+
private readonly onCtrlG?: () => void,
|
|
86
|
+
) {
|
|
87
|
+
this.items = items;
|
|
88
|
+
this.filteredItems = items;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
updateItems(items: ChecklistItem[]): void {
|
|
92
|
+
this.items = items;
|
|
93
|
+
this.applyFilter(this.searchInput.getValue());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setExtraHint(hint: string): void {
|
|
97
|
+
this.extraHint = hint;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
invalidate() {}
|
|
101
|
+
|
|
102
|
+
private applyFilter(query: string): void {
|
|
103
|
+
const normalized = query.toLowerCase().trim();
|
|
104
|
+
this.filteredItems = normalized
|
|
105
|
+
? this.items.filter(
|
|
106
|
+
(item) =>
|
|
107
|
+
item.id.toLowerCase().includes(normalized) ||
|
|
108
|
+
item.label.toLowerCase().includes(normalized),
|
|
109
|
+
)
|
|
110
|
+
: this.items;
|
|
111
|
+
this.selectedIndex = Math.max(
|
|
112
|
+
0,
|
|
113
|
+
Math.min(this.selectedIndex, this.filteredItems.length - 1),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
render(width: number): string[] {
|
|
118
|
+
const lines: string[] = [];
|
|
119
|
+
|
|
120
|
+
lines.push(
|
|
121
|
+
...this.searchInput.render(Math.max(1, width - 4)).map((l) => ` ${l}`),
|
|
122
|
+
);
|
|
123
|
+
lines.push("");
|
|
124
|
+
|
|
125
|
+
if (this.filteredItems.length === 0) {
|
|
126
|
+
lines.push(this.settingsTheme.hint(" No matching providers."));
|
|
127
|
+
return lines;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const startIndex = Math.max(
|
|
131
|
+
0,
|
|
132
|
+
Math.min(
|
|
133
|
+
this.selectedIndex - Math.floor(LIST_HEIGHT / 2),
|
|
134
|
+
this.filteredItems.length - LIST_HEIGHT,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
const endIndex = Math.min(
|
|
138
|
+
startIndex + LIST_HEIGHT,
|
|
139
|
+
this.filteredItems.length,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
143
|
+
const item = this.filteredItems[i];
|
|
144
|
+
if (!item) continue;
|
|
145
|
+
const selected = i === this.selectedIndex;
|
|
146
|
+
const prefix = selected ? this.settingsTheme.cursor : " ";
|
|
147
|
+
const check = item.checked ? "[x]" : "[ ]";
|
|
148
|
+
const label = truncateToWidth(
|
|
149
|
+
`${check} ${item.label}`,
|
|
150
|
+
Math.max(1, width - 6),
|
|
151
|
+
"…",
|
|
152
|
+
);
|
|
153
|
+
lines.push(`${prefix}${this.settingsTheme.value(` ${label}`, selected)}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
|
157
|
+
lines.push(
|
|
158
|
+
this.settingsTheme.hint(
|
|
159
|
+
` (${this.selectedIndex + 1}/${this.filteredItems.length})`,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
lines.push("");
|
|
165
|
+
const hints = ["↑↓: navigate", "Space: toggle"];
|
|
166
|
+
if (this.onCtrlG) hints.push("Ctrl+G: gateway check");
|
|
167
|
+
lines.push(this.settingsTheme.hint(` ${hints.join(" · ")}`));
|
|
168
|
+
if (this.extraHint) {
|
|
169
|
+
lines.push(
|
|
170
|
+
...wrapTextWithAnsi(this.extraHint, Math.max(1, width - 4)).map(
|
|
171
|
+
(line) => this.settingsTheme.hint(` ${line}`),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return lines;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
handleInput(data: string): void {
|
|
180
|
+
const kb = getKeybindings();
|
|
181
|
+
|
|
182
|
+
if (this.onCtrlG && matchesKey(data, Key.ctrl("g"))) {
|
|
183
|
+
this.onCtrlG();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
188
|
+
if (this.filteredItems.length === 0) return;
|
|
189
|
+
this.selectedIndex =
|
|
190
|
+
this.selectedIndex === 0
|
|
191
|
+
? this.filteredItems.length - 1
|
|
192
|
+
: this.selectedIndex - 1;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (kb.matches(data, "tui.select.down")) {
|
|
197
|
+
if (this.filteredItems.length === 0) return;
|
|
198
|
+
this.selectedIndex =
|
|
199
|
+
this.selectedIndex === this.filteredItems.length - 1
|
|
200
|
+
? 0
|
|
201
|
+
: this.selectedIndex + 1;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (kb.matches(data, "tui.select.pageUp")) {
|
|
206
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - LIST_HEIGHT);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (kb.matches(data, "tui.select.pageDown")) {
|
|
211
|
+
this.selectedIndex = Math.min(
|
|
212
|
+
Math.max(0, this.filteredItems.length - 1),
|
|
213
|
+
this.selectedIndex + LIST_HEIGHT,
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (data === " ") {
|
|
219
|
+
const item = this.filteredItems[this.selectedIndex];
|
|
220
|
+
if (item) this.onToggle(item.id);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.searchInput.handleInput(data);
|
|
225
|
+
this.applyFilter(this.searchInput.getValue());
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// --- Steps ---
|
|
229
|
+
|
|
230
|
+
class IntroStep implements Component {
|
|
231
|
+
private readonly introText = new Text("", 2, 0);
|
|
232
|
+
|
|
233
|
+
constructor(private readonly onNext: () => void) {}
|
|
234
|
+
|
|
235
|
+
invalidate() {
|
|
236
|
+
this.introText.invalidate();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
render(width: number): string[] {
|
|
240
|
+
this.introText.setText(
|
|
241
|
+
'Aperture lets you route LLM traffic through your Tailscale tailnet.\n\nYou can use it two ways:\n\n- Dedicated provider: a standalone "aperture" provider with all models from your gateway\n- Proxy: reroute existing Pi providers (e.g. anthropic, openai) through Aperture\n\nYou can change these settings later in /aperture:settings.',
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return [
|
|
245
|
+
" Welcome to Aperture",
|
|
246
|
+
"",
|
|
247
|
+
...this.introText.render(Math.max(1, width)),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
handleInput(data: string): void {
|
|
252
|
+
if (matchesKey(data, Key.enter)) {
|
|
253
|
+
this.onNext();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
class CapabilitiesStep implements Component {
|
|
259
|
+
private selectedIndex = 0;
|
|
260
|
+
private readonly settingsTheme: SettingsTheme;
|
|
261
|
+
|
|
262
|
+
constructor(
|
|
263
|
+
private readonly theme: Theme,
|
|
264
|
+
private readonly state: OnboardingState,
|
|
265
|
+
private readonly wizCtx: WizardStepContext,
|
|
266
|
+
private readonly onSelected: () => void,
|
|
267
|
+
) {
|
|
268
|
+
this.settingsTheme = getSettingsTheme(theme);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
invalidate() {}
|
|
272
|
+
|
|
273
|
+
render(width: number): string[] {
|
|
274
|
+
const options = ["Dedicated only", "Proxy only", "Both"];
|
|
275
|
+
const explanations = [
|
|
276
|
+
[
|
|
277
|
+
"Register a standalone `aperture` provider whose model list comes directly from your Aperture gateway.",
|
|
278
|
+
"",
|
|
279
|
+
"- All gateway models appear under one provider",
|
|
280
|
+
"- Uses `openai-completions` API for all models",
|
|
281
|
+
"- Models use default config (shared context window, no reasoning) since Aperture does not expose full model details yet",
|
|
282
|
+
].join("\n"),
|
|
283
|
+
[
|
|
284
|
+
"Reroute existing Pi providers (anthropic, openai, etc.) through Aperture, keeping their original model definitions.",
|
|
285
|
+
"",
|
|
286
|
+
"- Each provider keeps its own model list and settings",
|
|
287
|
+
"- Only the base URL and API key are overridden",
|
|
288
|
+
"- Useful when you want to keep per-provider model config",
|
|
289
|
+
].join("\n"),
|
|
290
|
+
[
|
|
291
|
+
"Enable both capabilities at the same time.",
|
|
292
|
+
"",
|
|
293
|
+
"- `aperture` exposes gateway models directly",
|
|
294
|
+
"- Selected existing Pi providers are also proxied",
|
|
295
|
+
"- The same gateway provider can be used in both capabilities",
|
|
296
|
+
].join("\n"),
|
|
297
|
+
];
|
|
298
|
+
|
|
299
|
+
const lines: string[] = [" How do you want to use Aperture?", ""];
|
|
300
|
+
|
|
301
|
+
for (let i = 0; i < options.length; i++) {
|
|
302
|
+
const option = options[i];
|
|
303
|
+
if (!option) continue;
|
|
304
|
+
const selected = i === this.selectedIndex;
|
|
305
|
+
const prefix = selected ? this.settingsTheme.cursor : " ";
|
|
306
|
+
const label = this.settingsTheme.value(` ${option}`, selected);
|
|
307
|
+
lines.push(`${prefix}${label}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
lines.push("");
|
|
311
|
+
|
|
312
|
+
const explanationBox = new Box(1, 0, (s: string) => s);
|
|
313
|
+
explanationBox.addChild(
|
|
314
|
+
new Markdown(
|
|
315
|
+
explanations[this.selectedIndex] ?? "",
|
|
316
|
+
0,
|
|
317
|
+
0,
|
|
318
|
+
getMarkdownTheme(),
|
|
319
|
+
{
|
|
320
|
+
color: (s: string) => this.theme.fg("text", s),
|
|
321
|
+
},
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
lines.push(...explanationBox.render(Math.max(1, width)));
|
|
326
|
+
|
|
327
|
+
return lines;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
handleInput(data: string): void {
|
|
331
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
332
|
+
this.selectedIndex =
|
|
333
|
+
this.selectedIndex === 0 ? 2 : this.selectedIndex - 1;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
337
|
+
this.selectedIndex =
|
|
338
|
+
this.selectedIndex === 2 ? 0 : this.selectedIndex + 1;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (matchesKey(data, Key.enter)) {
|
|
343
|
+
this.state.dedicatedEnabled =
|
|
344
|
+
this.selectedIndex === 0 || this.selectedIndex === 2;
|
|
345
|
+
this.state.proxyEnabled =
|
|
346
|
+
this.selectedIndex === 1 || this.selectedIndex === 2;
|
|
347
|
+
this.wizCtx.markComplete();
|
|
348
|
+
this.onSelected();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
class ProxyProvidersStep implements Component {
|
|
354
|
+
private readonly settingsTheme: SettingsTheme;
|
|
355
|
+
private providers: ReturnType<typeof mapProxyProviders> = [];
|
|
356
|
+
private readonly checked: Set<string>;
|
|
357
|
+
private checkAllGateway = true;
|
|
358
|
+
private loading = true;
|
|
359
|
+
private error = "";
|
|
360
|
+
private checklist: FilterableChecklist | null = null;
|
|
361
|
+
|
|
362
|
+
constructor(
|
|
363
|
+
theme: Theme,
|
|
364
|
+
private readonly tui: TUI,
|
|
365
|
+
private readonly state: OnboardingState,
|
|
366
|
+
private readonly knownModels: Model<Api>[],
|
|
367
|
+
private readonly wizCtx: WizardStepContext,
|
|
368
|
+
) {
|
|
369
|
+
this.settingsTheme = getSettingsTheme(theme);
|
|
370
|
+
this.checked = new Set(state.upstreamProviders.map((p) => p.id));
|
|
371
|
+
const allCheckedGateway =
|
|
372
|
+
state.upstreamProviders.length > 0 &&
|
|
373
|
+
state.upstreamProviders.every((p) => p.shouldCheckGatewayModels);
|
|
374
|
+
this.checkAllGateway = allCheckedGateway;
|
|
375
|
+
this.fetchProviders();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async fetchProviders(): Promise<void> {
|
|
379
|
+
try {
|
|
380
|
+
const client = new ApertureClient(this.state.baseUrl);
|
|
381
|
+
const [providerInfos, gatewayProviders] = await Promise.all([
|
|
382
|
+
client.providerConfigInfos(),
|
|
383
|
+
client.providers(),
|
|
384
|
+
]);
|
|
385
|
+
this.providers = mapProxyProviders(
|
|
386
|
+
this.knownModels,
|
|
387
|
+
providerInfos,
|
|
388
|
+
gatewayProviders,
|
|
389
|
+
this.state.upstreamProviders,
|
|
390
|
+
);
|
|
391
|
+
this.loading = false;
|
|
392
|
+
this.saveState();
|
|
393
|
+
this.tui.requestRender();
|
|
394
|
+
} catch {
|
|
395
|
+
this.error = "Failed to fetch providers from gateway";
|
|
396
|
+
this.loading = false;
|
|
397
|
+
this.tui.requestRender();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private buildItems(): ChecklistItem[] {
|
|
402
|
+
return this.providers.map((provider) => ({
|
|
403
|
+
id: provider.id,
|
|
404
|
+
label: provider.name ?? provider.id,
|
|
405
|
+
checked: this.checked.has(provider.id),
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
invalidate() {}
|
|
410
|
+
|
|
411
|
+
render(width: number): string[] {
|
|
412
|
+
if (this.loading) {
|
|
413
|
+
return [
|
|
414
|
+
" Fetching proxy providers from Aperture gateway...",
|
|
415
|
+
"",
|
|
416
|
+
this.settingsTheme.hint(" Please wait."),
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (this.error) {
|
|
421
|
+
return [` ${this.error}`, ""];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (this.providers.length === 0) {
|
|
425
|
+
return [
|
|
426
|
+
" No local providers match the Aperture gateway provider base URLs.",
|
|
427
|
+
"",
|
|
428
|
+
" You can add proxy providers later in /aperture:settings.",
|
|
429
|
+
];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!this.checklist) {
|
|
433
|
+
this.checklist = new FilterableChecklist(
|
|
434
|
+
this.settingsTheme,
|
|
435
|
+
this.buildItems(),
|
|
436
|
+
(id) => this.toggleProvider(id),
|
|
437
|
+
() => this.toggleGatewayCheck(),
|
|
438
|
+
);
|
|
439
|
+
} else {
|
|
440
|
+
this.checklist.updateItems(this.buildItems());
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const gwLabel = this.checkAllGateway ? "on" : "off";
|
|
444
|
+
this.checklist.setExtraHint(
|
|
445
|
+
`gateway model check: ${gwLabel} — warns if local provider models are missing from Aperture`,
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
return [
|
|
449
|
+
" Select providers to route through Aperture:",
|
|
450
|
+
"",
|
|
451
|
+
...this.checklist.render(width),
|
|
452
|
+
];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private toggleProvider(id: string): void {
|
|
456
|
+
if (this.checked.has(id)) {
|
|
457
|
+
this.checked.delete(id);
|
|
458
|
+
} else {
|
|
459
|
+
this.checked.add(id);
|
|
460
|
+
}
|
|
461
|
+
this.saveState();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private toggleGatewayCheck(): void {
|
|
465
|
+
this.checkAllGateway = !this.checkAllGateway;
|
|
466
|
+
this.saveState();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
handleInput(data: string): void {
|
|
470
|
+
if (this.loading) return;
|
|
471
|
+
if (matchesKey(data, Key.enter)) {
|
|
472
|
+
this.wizCtx.markComplete();
|
|
473
|
+
this.wizCtx.goNext();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (this.checklist) {
|
|
477
|
+
this.checklist.handleInput(data);
|
|
478
|
+
if (data === " ") this.wizCtx.markComplete();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private saveState(): void {
|
|
483
|
+
this.state.upstreamProviders = this.providers
|
|
484
|
+
.map((provider) => provider.id)
|
|
485
|
+
.filter((id) => this.checked.has(id))
|
|
486
|
+
.map((id) => ({
|
|
487
|
+
id,
|
|
488
|
+
shouldCheckGatewayModels: this.checkAllGateway,
|
|
489
|
+
}));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
class FinishStep implements Component {
|
|
494
|
+
private readonly recapMarkdown = new Markdown("", 2, 0, getMarkdownTheme());
|
|
495
|
+
|
|
496
|
+
constructor(
|
|
497
|
+
private readonly state: OnboardingState,
|
|
498
|
+
private readonly onFinish: () => void,
|
|
499
|
+
) {}
|
|
500
|
+
|
|
501
|
+
invalidate() {
|
|
502
|
+
this.recapMarkdown.invalidate();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
render(width: number): string[] {
|
|
506
|
+
const capabilityLabel = this.state.dedicatedEnabled
|
|
507
|
+
? this.state.proxyEnabled
|
|
508
|
+
? "Dedicated provider and proxy"
|
|
509
|
+
: "Dedicated provider"
|
|
510
|
+
: "Proxy existing providers";
|
|
511
|
+
|
|
512
|
+
let content = `**URL**: \`${this.state.baseUrl || "(not set)"}\`\n\n**Capabilities**: ${capabilityLabel}`;
|
|
513
|
+
|
|
514
|
+
if (this.state.proxyEnabled) {
|
|
515
|
+
const count = this.state.upstreamProviders.length;
|
|
516
|
+
if (count > 0) {
|
|
517
|
+
const list = this.state.upstreamProviders
|
|
518
|
+
.map(
|
|
519
|
+
(p) =>
|
|
520
|
+
`- \`${p.id}\`${
|
|
521
|
+
p.shouldCheckGatewayModels ? " (gateway check on)" : ""
|
|
522
|
+
}`,
|
|
523
|
+
)
|
|
524
|
+
.join("\n");
|
|
525
|
+
content += `\n\n**Upstream providers** (${count}):\n${list}`;
|
|
526
|
+
} else {
|
|
527
|
+
content += "\n\n**Upstream providers**: none selected";
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (this.state.dedicatedEnabled) {
|
|
532
|
+
const enabled = this.state.dedicatedProviders.filter((p) => p.enabled);
|
|
533
|
+
const disabled = this.state.dedicatedProviders.filter((p) => !p.enabled);
|
|
534
|
+
if (enabled.length > 0) {
|
|
535
|
+
const list = enabled.map((p) => `- \`${p.name ?? p.id}\``).join("\n");
|
|
536
|
+
content += `\n\n**Aperture providers** (${enabled.length}):\n${list}`;
|
|
537
|
+
}
|
|
538
|
+
if (disabled.length > 0) {
|
|
539
|
+
const list = disabled
|
|
540
|
+
.map((p) => `- ~~\`${p.name ?? p.id}\`~~`)
|
|
541
|
+
.join("\n");
|
|
542
|
+
content += `\n\n**Excluded** (${disabled.length}):\n${list}`;
|
|
543
|
+
}
|
|
544
|
+
if (this.state.dedicatedProviders.length === 0) {
|
|
545
|
+
content += "\n\n**Aperture providers**: all (no filter)";
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
this.recapMarkdown.setText(content);
|
|
550
|
+
return [...this.recapMarkdown.render(Math.max(1, width)), ""];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
handleInput(data: string): void {
|
|
554
|
+
if (matchesKey(data, Key.enter)) {
|
|
555
|
+
this.onFinish();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// --- Wizard factory ---
|
|
561
|
+
|
|
562
|
+
export function createOnboardingWizard(
|
|
563
|
+
theme: Theme,
|
|
564
|
+
tui: TUI,
|
|
565
|
+
done: (result: OnboardingResult) => void,
|
|
566
|
+
knownModels: Model<Api>[],
|
|
567
|
+
currentConfig: ApertureConfig | null,
|
|
568
|
+
): Component {
|
|
569
|
+
const state: OnboardingState = {
|
|
570
|
+
baseUrl: currentConfig?.baseUrl ?? "",
|
|
571
|
+
proxyEnabled: currentConfig?.proxy?.enabled ?? false,
|
|
572
|
+
dedicatedEnabled: currentConfig?.dedicated?.enabled ?? true,
|
|
573
|
+
upstreamProviders:
|
|
574
|
+
currentConfig?.proxy?.upstreamProviders?.map((p) => ({
|
|
575
|
+
id: p.id,
|
|
576
|
+
shouldCheckGatewayModels: p.shouldCheckGatewayModels ?? false,
|
|
577
|
+
})) ?? [],
|
|
578
|
+
dedicatedProviders:
|
|
579
|
+
currentConfig?.dedicated?.providers?.map((p) => ({
|
|
580
|
+
id: p.id,
|
|
581
|
+
name: p.name,
|
|
582
|
+
enabled: p.enabled,
|
|
583
|
+
})) ?? [],
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
let markWelcomeComplete: (() => void) | null = null;
|
|
587
|
+
let wizard: Wizard;
|
|
588
|
+
let settled = false;
|
|
589
|
+
|
|
590
|
+
const finalize = (result: OnboardingResult) => {
|
|
591
|
+
if (settled) return;
|
|
592
|
+
settled = true;
|
|
593
|
+
done(result);
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const finish = (markComplete: () => void) => {
|
|
597
|
+
if (!state.proxyEnabled && !state.dedicatedEnabled) return;
|
|
598
|
+
markComplete();
|
|
599
|
+
finalize({
|
|
600
|
+
completed: true,
|
|
601
|
+
baseUrl: state.baseUrl,
|
|
602
|
+
proxyEnabled: state.proxyEnabled,
|
|
603
|
+
dedicatedEnabled: state.dedicatedEnabled,
|
|
604
|
+
upstreamProviders: state.upstreamProviders,
|
|
605
|
+
dedicatedProviders: state.dedicatedProviders,
|
|
606
|
+
});
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const buildWizard = (activeLabel?: string): Wizard => {
|
|
610
|
+
const steps = [
|
|
611
|
+
{
|
|
612
|
+
label: "Welcome",
|
|
613
|
+
build: (ctx: WizardStepContext) => {
|
|
614
|
+
markWelcomeComplete = ctx.markComplete;
|
|
615
|
+
return new IntroStep(() => {
|
|
616
|
+
ctx.markComplete();
|
|
617
|
+
ctx.goNext();
|
|
618
|
+
});
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
label: "URL",
|
|
623
|
+
build: (ctx: WizardStepContext) =>
|
|
624
|
+
new UrlStep(
|
|
625
|
+
getSettingsTheme(theme),
|
|
626
|
+
tui,
|
|
627
|
+
state.baseUrl,
|
|
628
|
+
ctx,
|
|
629
|
+
(url) => {
|
|
630
|
+
state.baseUrl = url;
|
|
631
|
+
},
|
|
632
|
+
),
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
label: "Capabilities",
|
|
636
|
+
build: (ctx: WizardStepContext) =>
|
|
637
|
+
new CapabilitiesStep(theme, state, ctx, () => {
|
|
638
|
+
const nextLabel = state.dedicatedEnabled
|
|
639
|
+
? "Dedicated"
|
|
640
|
+
: state.proxyEnabled
|
|
641
|
+
? "Proxy"
|
|
642
|
+
: "Recap";
|
|
643
|
+
wizard = buildWizard(nextLabel);
|
|
644
|
+
tui.requestRender();
|
|
645
|
+
}),
|
|
646
|
+
},
|
|
647
|
+
...(state.dedicatedEnabled
|
|
648
|
+
? [
|
|
649
|
+
{
|
|
650
|
+
label: "Dedicated",
|
|
651
|
+
build: (ctx: WizardStepContext) =>
|
|
652
|
+
new DedicatedProvidersStep(theme, tui, state, ctx),
|
|
653
|
+
},
|
|
654
|
+
]
|
|
655
|
+
: []),
|
|
656
|
+
...(state.proxyEnabled
|
|
657
|
+
? [
|
|
658
|
+
{
|
|
659
|
+
label: "Proxy",
|
|
660
|
+
build: (ctx: WizardStepContext) =>
|
|
661
|
+
new ProxyProvidersStep(theme, tui, state, knownModels, ctx),
|
|
662
|
+
},
|
|
663
|
+
]
|
|
664
|
+
: []),
|
|
665
|
+
{
|
|
666
|
+
label: "Recap",
|
|
667
|
+
build: (ctx: WizardStepContext) =>
|
|
668
|
+
new FinishStep(state, () => finish(ctx.markComplete)),
|
|
669
|
+
},
|
|
670
|
+
];
|
|
671
|
+
|
|
672
|
+
const nextWizard = new Wizard({
|
|
673
|
+
title: "Aperture Setup",
|
|
674
|
+
theme,
|
|
675
|
+
steps,
|
|
676
|
+
onComplete: () => {
|
|
677
|
+
finalize({
|
|
678
|
+
completed: state.proxyEnabled || state.dedicatedEnabled,
|
|
679
|
+
baseUrl: state.baseUrl,
|
|
680
|
+
proxyEnabled: state.proxyEnabled,
|
|
681
|
+
dedicatedEnabled: state.dedicatedEnabled,
|
|
682
|
+
upstreamProviders: state.upstreamProviders,
|
|
683
|
+
dedicatedProviders: state.dedicatedProviders,
|
|
684
|
+
});
|
|
685
|
+
},
|
|
686
|
+
onCancel: () =>
|
|
687
|
+
finalize({
|
|
688
|
+
completed: false,
|
|
689
|
+
baseUrl: state.baseUrl,
|
|
690
|
+
proxyEnabled: state.proxyEnabled,
|
|
691
|
+
dedicatedEnabled: state.dedicatedEnabled,
|
|
692
|
+
upstreamProviders: state.upstreamProviders,
|
|
693
|
+
dedicatedProviders: state.dedicatedProviders,
|
|
694
|
+
}),
|
|
695
|
+
hintSuffix: "Enter select/continue",
|
|
696
|
+
minContentHeight: 14,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const activeIndex = activeLabel
|
|
700
|
+
? steps.findIndex((step) => step.label === activeLabel)
|
|
701
|
+
: 0;
|
|
702
|
+
if (activeIndex > 0) {
|
|
703
|
+
const wizardState = nextWizard as unknown as {
|
|
704
|
+
activeIndex: number;
|
|
705
|
+
completed: boolean[];
|
|
706
|
+
};
|
|
707
|
+
wizardState.activeIndex = activeIndex;
|
|
708
|
+
for (let i = 0; i < activeIndex; i++) {
|
|
709
|
+
wizardState.completed[i] = true;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return nextWizard;
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
wizard = buildWizard();
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
render: (width: number) => wizard.render(width),
|
|
719
|
+
invalidate: () => wizard.invalidate(),
|
|
720
|
+
handleInput: (data: string) => {
|
|
721
|
+
if (
|
|
722
|
+
matchesKey(data, Key.tab) &&
|
|
723
|
+
wizard.getActiveIndex() === 0 &&
|
|
724
|
+
markWelcomeComplete
|
|
725
|
+
) {
|
|
726
|
+
markWelcomeComplete();
|
|
727
|
+
}
|
|
728
|
+
wizard.handleInput(data);
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Dedicated mode: select which Aperture gateway providers to include. */
|
|
734
|
+
class DedicatedProvidersStep implements Component {
|
|
735
|
+
private readonly settingsTheme: SettingsTheme;
|
|
736
|
+
private providers: DedicatedProviderConfig[] = [];
|
|
737
|
+
private readonly enabled: Set<string>;
|
|
738
|
+
private loading = true;
|
|
739
|
+
private error = "";
|
|
740
|
+
private checklist: FilterableChecklist | null = null;
|
|
741
|
+
|
|
742
|
+
constructor(
|
|
743
|
+
theme: Theme,
|
|
744
|
+
private readonly tui: TUI,
|
|
745
|
+
private readonly state: OnboardingState,
|
|
746
|
+
private readonly wizCtx: WizardStepContext,
|
|
747
|
+
) {
|
|
748
|
+
this.settingsTheme = getSettingsTheme(theme);
|
|
749
|
+
this.enabled = new Set(
|
|
750
|
+
state.dedicatedProviders.filter((p) => p.enabled).map((p) => p.id),
|
|
751
|
+
);
|
|
752
|
+
this.fetchProviders();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private async fetchProviders(): Promise<void> {
|
|
756
|
+
try {
|
|
757
|
+
const gatewayProviders = await new ApertureClient(
|
|
758
|
+
this.state.baseUrl,
|
|
759
|
+
).providers();
|
|
760
|
+
this.providers = mapDedicatedProviders(
|
|
761
|
+
gatewayProviders,
|
|
762
|
+
this.state.dedicatedProviders,
|
|
763
|
+
);
|
|
764
|
+
this.enabled.clear();
|
|
765
|
+
for (const provider of this.providers) {
|
|
766
|
+
if (provider.enabled) this.enabled.add(provider.id);
|
|
767
|
+
}
|
|
768
|
+
this.loading = false;
|
|
769
|
+
this.saveState();
|
|
770
|
+
this.tui.requestRender();
|
|
771
|
+
} catch {
|
|
772
|
+
this.error = "Failed to fetch providers from gateway";
|
|
773
|
+
this.loading = false;
|
|
774
|
+
this.tui.requestRender();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private buildItems(): ChecklistItem[] {
|
|
779
|
+
return this.providers.map((p) => ({
|
|
780
|
+
id: p.id,
|
|
781
|
+
label: p.name ?? p.id,
|
|
782
|
+
checked: this.enabled.has(p.id),
|
|
783
|
+
}));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
invalidate() {}
|
|
787
|
+
|
|
788
|
+
render(width: number): string[] {
|
|
789
|
+
if (this.loading) {
|
|
790
|
+
return [
|
|
791
|
+
" Fetching providers from Aperture gateway...",
|
|
792
|
+
"",
|
|
793
|
+
this.settingsTheme.hint(" Please wait."),
|
|
794
|
+
];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (this.error) {
|
|
798
|
+
return [` ${this.error}`, ""];
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (this.providers.length === 0) {
|
|
802
|
+
return [" No providers found on the Aperture gateway.", ""];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (!this.checklist) {
|
|
806
|
+
this.checklist = new FilterableChecklist(
|
|
807
|
+
this.settingsTheme,
|
|
808
|
+
this.buildItems(),
|
|
809
|
+
(id) => this.toggleProvider(id),
|
|
810
|
+
);
|
|
811
|
+
} else {
|
|
812
|
+
this.checklist.updateItems(this.buildItems());
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return [
|
|
816
|
+
" Select Aperture providers to include:",
|
|
817
|
+
"",
|
|
818
|
+
...this.checklist.render(width),
|
|
819
|
+
];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private toggleProvider(id: string): void {
|
|
823
|
+
if (this.enabled.has(id)) {
|
|
824
|
+
this.enabled.delete(id);
|
|
825
|
+
} else {
|
|
826
|
+
this.enabled.add(id);
|
|
827
|
+
}
|
|
828
|
+
this.saveState();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
handleInput(data: string): void {
|
|
832
|
+
if (this.loading) return;
|
|
833
|
+
if (matchesKey(data, Key.enter)) {
|
|
834
|
+
this.wizCtx.markComplete();
|
|
835
|
+
this.wizCtx.goNext();
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (this.checklist) {
|
|
839
|
+
this.checklist.handleInput(data);
|
|
840
|
+
if (data === " ") this.wizCtx.markComplete();
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
private saveState(): void {
|
|
845
|
+
this.state.dedicatedProviders = this.providers.map((p) => ({
|
|
846
|
+
id: p.id,
|
|
847
|
+
name: p.name,
|
|
848
|
+
enabled: this.enabled.has(p.id),
|
|
849
|
+
}));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// --- Public helpers ---
|
|
854
|
+
|
|
855
|
+
export function isOnboardingPending(config: ApertureConfig | null): boolean {
|
|
856
|
+
if (!config) return true;
|
|
857
|
+
return config.onboardingDone !== true;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function isOnboardingExtensionEnabled(
|
|
861
|
+
config: ApertureConfig | null,
|
|
862
|
+
): boolean {
|
|
863
|
+
if (!config) return true;
|
|
864
|
+
return config.onboarding?.enabled ?? config.onboardingDone !== true;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
export function buildOnboardedConfig(
|
|
868
|
+
baseUrl: string,
|
|
869
|
+
proxyEnabled: boolean,
|
|
870
|
+
dedicatedEnabled: boolean,
|
|
871
|
+
upstreamProviders: { id: string; shouldCheckGatewayModels: boolean }[],
|
|
872
|
+
dedicatedProviders: DedicatedProviderConfig[],
|
|
873
|
+
): ApertureConfig {
|
|
874
|
+
return {
|
|
875
|
+
baseUrl,
|
|
876
|
+
onboardingDone: true,
|
|
877
|
+
onboarding: {
|
|
878
|
+
enabled: false,
|
|
879
|
+
},
|
|
880
|
+
proxy: {
|
|
881
|
+
enabled: proxyEnabled,
|
|
882
|
+
upstreamProviders: upstreamProviders.map((p) => ({
|
|
883
|
+
id: p.id,
|
|
884
|
+
shouldCheckGatewayModels: p.shouldCheckGatewayModels,
|
|
885
|
+
})),
|
|
886
|
+
},
|
|
887
|
+
dedicated: {
|
|
888
|
+
enabled: dedicatedEnabled,
|
|
889
|
+
providers: dedicatedProviders,
|
|
890
|
+
},
|
|
891
|
+
};
|
|
892
|
+
}
|