@dotdrelle/wiki-manager 0.15.32 → 0.15.35
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/.env.example +8 -5
- package/agents.docker-compose.yml +11 -16
- package/docker-compose.yml +9 -8
- package/package.json +2 -2
- package/src/agent/graph.js +20 -0
- package/src/commands/slash.js +73 -10
- package/src/commands/slash.test.js +37 -3
- package/src/core/agentsCompose.js +86 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +24 -2
- package/src/core/dockerCompose.test.js +18 -0
- package/src/core/googleGrants.js +38 -0
- package/src/core/googleGrants.test.js +59 -0
- package/src/core/mcp.js +1 -1
- package/src/core/modelFetch.js +449 -47
- package/src/core/modelFetch.test.js +290 -2
- package/src/core/profileServiceStatus.test.js +146 -0
- package/src/core/wikiSetup.js +43 -4
- package/src/core/wikiWorkspace.test.js +9 -3
- package/src/core/wikirc.test.js +59 -5
- package/src/runtime/lifecycle.js +105 -3
- package/src/runtime/lifecycle.test.js +68 -0
- package/src/shell/SetupWizard.tsx +617 -66
- package/src/shell/repl.js +37 -11
- package/src/shell/setupWizardDiscovery.test.js +48 -0
- package/src/shell/setupWizardPlaceholders.test.js +16 -3
- package/src/shell/setupWizardSuggestions.test.js +58 -0
- package/src/shell/wrapText.js +57 -0
- package/src/shell/wrapText.test.js +48 -0
- package/wiki-workspace +116 -32
|
@@ -3,7 +3,17 @@ import { execFileSync } from 'node:child_process';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { useKeyboard, usePaste } from '@opentui/solid';
|
|
5
5
|
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
DISCOVERY_TIMEOUT_MS,
|
|
8
|
+
defaultBaseUrl,
|
|
9
|
+
fallbackModels,
|
|
10
|
+
fetchGatewayCatalog,
|
|
11
|
+
fetchServerCatalog,
|
|
12
|
+
normalizeEngine,
|
|
13
|
+
normalizeProvider,
|
|
14
|
+
requiresBaseUrl,
|
|
15
|
+
transportSummary,
|
|
16
|
+
} from '../core/modelFetch.js';
|
|
7
17
|
import { checkInternetConnectivity } from '../core/startupCheck.js';
|
|
8
18
|
import {
|
|
9
19
|
createNewWorkspace,
|
|
@@ -17,6 +27,7 @@ import {
|
|
|
17
27
|
} from '../core/wikiSetup.js';
|
|
18
28
|
import { listWorkspaces, workspacesDir } from '../core/workspaces.js';
|
|
19
29
|
import { loadWikircProfile } from '../core/wikirc.js';
|
|
30
|
+
import { wrapText } from './wrapText.js';
|
|
20
31
|
|
|
21
32
|
type Gap = { kind: 'agents' | 'network' | 'workspace' | 'llm' | 'vector'; context?: Record<string, any> };
|
|
22
33
|
type Mode = 'startup' | 'setup';
|
|
@@ -24,11 +35,42 @@ type Step =
|
|
|
24
35
|
| { kind: 'menu'; title: string; items: Array<{ label: string; value: string; muted?: boolean }> }
|
|
25
36
|
| { kind: 'confirm'; title: string; message: string; yesLabel: string; noLabel: string }
|
|
26
37
|
| { kind: 'select'; title: string; label: string; options: string[]; note?: string }
|
|
27
|
-
| {
|
|
38
|
+
| {
|
|
39
|
+
kind: 'text';
|
|
40
|
+
title: string;
|
|
41
|
+
label: string;
|
|
42
|
+
note?: string;
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
prefill?: string;
|
|
45
|
+
secret?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Catalogue découvert. Purement indicatif : le champ texte fait foi, ce
|
|
48
|
+
* qui garde l'étape utilisable quand l'endpoint est injoignable ou quand
|
|
49
|
+
* le modèle voulu n'y figure pas.
|
|
50
|
+
*/
|
|
51
|
+
suggestions?: string[];
|
|
52
|
+
/** Valeur configurée écartée parce qu'absente du catalogue découvert. */
|
|
53
|
+
stale?: string | null;
|
|
54
|
+
}
|
|
28
55
|
| { kind: 'done' };
|
|
29
56
|
type LogEntry = { icon: string; label: string; detail?: string };
|
|
30
57
|
|
|
31
|
-
|
|
58
|
+
// Deux axes, deux questions. `provider` dit où l'on tape, `engine` dit
|
|
59
|
+
// comment se comporte le serveur en face. Les fusionner était précisément ce
|
|
60
|
+
// qui empêchait de décrire une gateway.
|
|
61
|
+
const PROVIDERS = [
|
|
62
|
+
'Direct server (OpenAI-compatible)',
|
|
63
|
+
'AI gateway (LiteLLM, Bifrost, Portkey…)',
|
|
64
|
+
];
|
|
65
|
+
const ENGINE_OPTIONS = [
|
|
66
|
+
'OpenAI',
|
|
67
|
+
'Anthropic',
|
|
68
|
+
'Ollama (local)',
|
|
69
|
+
'vLLM (local)',
|
|
70
|
+
'MLX (local)',
|
|
71
|
+
'Albert',
|
|
72
|
+
'Other (generic OpenAI-compatible)',
|
|
73
|
+
];
|
|
32
74
|
// The scaffolded .wikirc.yaml ships fake endpoints and secrets so the file
|
|
33
75
|
// documents its own shape (`https://mon-provider.example.com/v1`,
|
|
34
76
|
// `http://infinity.local:7997/v1`, `YOUR_LLM_API_KEY`…). Preloading them into
|
|
@@ -43,12 +85,10 @@ function configuredValue(value: unknown) {
|
|
|
43
85
|
}
|
|
44
86
|
const MAIN_MENU = ['Agents', 'Workspaces', 'LLM configuration', 'Vector search', '---', 'Close'];
|
|
45
87
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (provider === 'openai-compatible') return 'http://localhost:8000';
|
|
51
|
-
return '';
|
|
88
|
+
// Les défauts et la question « faut-il demander une baseUrl ? » vivent dans
|
|
89
|
+
// core/modelFetch.js, source unique partagée avec la découverte.
|
|
90
|
+
function exampleBaseUrl(provider: string, engine: string) {
|
|
91
|
+
return defaultBaseUrl(provider, engine);
|
|
52
92
|
}
|
|
53
93
|
|
|
54
94
|
function currentWorkspaceContext(session: any, fallback?: any) {
|
|
@@ -113,6 +153,35 @@ function stepTitle(step: Step) {
|
|
|
113
153
|
return step.kind === 'done' ? 'Setup complete' : step.title;
|
|
114
154
|
}
|
|
115
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Partie « champ » d'une étape modèle : préremplissage, indication, catalogue.
|
|
158
|
+
*
|
|
159
|
+
* Le préremplissage ne reprend que ce qui est *déjà configuré*. Choisir pour
|
|
160
|
+
* l'opérateur le premier modèle découvert le filtrait aussitôt sur lui-même —
|
|
161
|
+
* la liste n'affichait plus qu'une entrée sur dix — et proposait une réponse
|
|
162
|
+
* qui n'avait aucune raison d'être la bonne. Quand rien n'est configuré, le
|
|
163
|
+
* champ reste vide et c'est l'indication grisée qui montre la forme attendue.
|
|
164
|
+
*/
|
|
165
|
+
function suggestionField(discovered: string[], configured?: string | null, example = '') {
|
|
166
|
+
// Un modèle absent du catalogue ne doit pas être préremplí : il filtrerait
|
|
167
|
+
// la liste sur zéro résultat, et l'écran afficherait « No match » devant dix
|
|
168
|
+
// modèles disponibles. C'est exactement le cas du `BAAI/bge-m3` du scaffold
|
|
169
|
+
// face à un serveur qui nomme le même modèle autrement.
|
|
170
|
+
const usable = configured && (discovered.length === 0 || discovered.includes(configured))
|
|
171
|
+
? configured
|
|
172
|
+
: null;
|
|
173
|
+
return {
|
|
174
|
+
// Sans catalogue il n'y a pas de liste à masquer : l'exemple redevient un
|
|
175
|
+
// préremplissage utile plutôt qu'un choix imposé.
|
|
176
|
+
prefill: usable ?? (discovered.length === 0 ? example : ''),
|
|
177
|
+
placeholder: discovered.length > 0 ? '↑↓ to browse the list, or type a name' : example,
|
|
178
|
+
suggestions: discovered,
|
|
179
|
+
/** Rappel affiché quand la valeur configurée a été écartée. */
|
|
180
|
+
stale: configured && !usable ? configured : null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
116
185
|
export function SetupWizard(props: {
|
|
117
186
|
mode: Mode;
|
|
118
187
|
session?: any;
|
|
@@ -131,6 +200,15 @@ export function SetupWizard(props: {
|
|
|
131
200
|
const [stepIndex, setStepIndex] = createSignal(0);
|
|
132
201
|
const [selected, setSelected] = createSignal(0);
|
|
133
202
|
const [input, setInput] = createSignal('');
|
|
203
|
+
/**
|
|
204
|
+
* Entrée survolée dans la liste des modèles, `-1` quand on tape librement.
|
|
205
|
+
*
|
|
206
|
+
* La liste n'était qu'un rappel : impossible d'y naviguer, donc le wizard
|
|
207
|
+
* préremplissait le premier modèle découvert pour qu'il y ait au moins une
|
|
208
|
+
* réponse. Ce préremplissage filtrait la liste sur lui-même — on ne voyait
|
|
209
|
+
* plus qu'un modèle sur dix, et rarement le bon.
|
|
210
|
+
*/
|
|
211
|
+
const [highlight, setHighlight] = createSignal(-1);
|
|
134
212
|
const [busy, setBusy] = createSignal(false);
|
|
135
213
|
const [error, setError] = createSignal<string | null>(null);
|
|
136
214
|
|
|
@@ -147,8 +225,14 @@ export function SetupWizard(props: {
|
|
|
147
225
|
});
|
|
148
226
|
|
|
149
227
|
const currentGap = () => startupGaps()[stepIndex()];
|
|
150
|
-
|
|
151
|
-
|
|
228
|
+
// Le dialogue occupe désormais l'essentiel du terminal. Les libellés, notes
|
|
229
|
+
// et causes d'erreur sont des phrases, pas des étiquettes : à 72 colonnes et
|
|
230
|
+
// 30 lignes elles débordaient, alors que la moitié basse du cadre restait
|
|
231
|
+
// vide.
|
|
232
|
+
const dialogWidth = () => Math.max(50, Math.min(110, Math.floor(props.width * 0.86)));
|
|
233
|
+
const dialogHeight = () => Math.max(24, Math.min(40, Math.floor(props.height * 0.86)));
|
|
234
|
+
/** Largeur utile : bordure (1) + padding (1) de chaque côté. */
|
|
235
|
+
const textWidth = () => Math.max(20, dialogWidth() - 4);
|
|
152
236
|
const left = () => Math.max(1, Math.floor((props.width - dialogWidth()) / 2));
|
|
153
237
|
const top = () => Math.max(1, Math.floor((props.height - dialogHeight()) / 2));
|
|
154
238
|
|
|
@@ -239,31 +323,59 @@ export function SetupWizard(props: {
|
|
|
239
323
|
kind: 'select',
|
|
240
324
|
title: 'LLM configuration',
|
|
241
325
|
label: context?.configError
|
|
242
|
-
? `${context.configError} Select
|
|
243
|
-
: `No LLM configured${context?.workspaceName ? ` for ${context.workspaceName}` : ''}.
|
|
326
|
+
? `${context.configError} Select how requests are routed after creating or fixing the config:`
|
|
327
|
+
: `No LLM configured${context?.workspaceName ? ` for ${context.workspaceName}` : ''}. How are requests routed?`,
|
|
244
328
|
options: PROVIDERS,
|
|
329
|
+
note: 'A gateway is external infrastructure you deploy yourself; llm-wiki only reads its model catalog.',
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (currentRoute === 'llm-engine') {
|
|
333
|
+
return {
|
|
334
|
+
kind: 'select',
|
|
335
|
+
title: 'LLM configuration',
|
|
336
|
+
label: 'Which server is answering?',
|
|
337
|
+
options: ENGINE_OPTIONS,
|
|
338
|
+
note: 'Drives request-shaping workarounds and the `wiki doctor` calibration.',
|
|
245
339
|
};
|
|
246
340
|
}
|
|
247
341
|
if (currentRoute === 'llm-baseurl') {
|
|
248
|
-
const
|
|
342
|
+
const isGateway = llm().provider === 'ai-gateway';
|
|
343
|
+
const example = exampleBaseUrl(llm().provider, llm().engine);
|
|
249
344
|
return {
|
|
250
345
|
kind: 'text',
|
|
251
346
|
title: 'LLM configuration',
|
|
252
|
-
label:
|
|
347
|
+
label: isGateway ? 'Gateway base URL' : 'Base URL',
|
|
348
|
+
note: isGateway
|
|
349
|
+
? 'Example: http://gateway:4000/v1'
|
|
350
|
+
: example
|
|
351
|
+
? `Example: ${example}`
|
|
352
|
+
: undefined,
|
|
253
353
|
prefill: llm().baseUrl || '',
|
|
254
354
|
};
|
|
255
355
|
}
|
|
256
356
|
if (currentRoute === 'llm-apikey') {
|
|
257
|
-
return {
|
|
357
|
+
return {
|
|
358
|
+
kind: 'text',
|
|
359
|
+
title: 'LLM configuration',
|
|
360
|
+
label: 'API key',
|
|
361
|
+
note: 'Required. The model catalog is read in the background right after.',
|
|
362
|
+
secret: true,
|
|
363
|
+
};
|
|
258
364
|
}
|
|
259
365
|
if (currentRoute === 'llm-model') {
|
|
260
|
-
const
|
|
366
|
+
const discovered = catalog()?.chat ?? [];
|
|
261
367
|
return {
|
|
262
368
|
kind: 'text',
|
|
263
369
|
title: 'LLM configuration',
|
|
264
|
-
|
|
370
|
+
// L'exemple vit dans l'indication du champ et dans la liste découverte ;
|
|
371
|
+
// le répéter dans le libellé en faisait la ligne la plus longue de
|
|
372
|
+
// l'écran, pour une information déjà visible deux fois.
|
|
373
|
+
label: 'Chat model',
|
|
265
374
|
note: 'Required: an agentic model with tool/function calling support.',
|
|
266
|
-
|
|
375
|
+
// Préremplir avec le premier modèle découvert filtrait la liste sur
|
|
376
|
+
// lui-même : neuf modèles sur dix devenaient invisibles, et la réponse
|
|
377
|
+
// proposée était arbitraire. Seul un modèle déjà configuré est repris.
|
|
378
|
+
...suggestionField(discovered, llm().model, fallbackModels(llm().engine)[0] ?? 'provider-agentic-model'),
|
|
267
379
|
};
|
|
268
380
|
}
|
|
269
381
|
if (currentRoute === 'vector-confirm') {
|
|
@@ -274,19 +386,45 @@ export function SetupWizard(props: {
|
|
|
274
386
|
return { kind: 'text', title: 'Vector search', label: 'Embeddings/rerank base URL', prefill: baseUrl, placeholder: baseUrl };
|
|
275
387
|
}
|
|
276
388
|
if (currentRoute === 'vector-apikey') {
|
|
277
|
-
|
|
278
|
-
|
|
389
|
+
// L'héritage n'est proposé que tant que l'URL n'a pas divergé : sinon la
|
|
390
|
+
// clé du LLM — celle de la gateway, qui ouvre tous les providers —
|
|
391
|
+
// partirait vers un autre hôte.
|
|
392
|
+
const diverged = vectorBaseUrlDiverged();
|
|
393
|
+
const hint = !diverged && llm().apiKey ? '(leave empty to reuse LLM key)' : undefined;
|
|
394
|
+
return {
|
|
395
|
+
kind: 'text',
|
|
396
|
+
title: 'Vector search',
|
|
397
|
+
label: diverged ? 'Vector API key (required: different host)' : 'Vector API key',
|
|
398
|
+
placeholder: hint,
|
|
399
|
+
secret: true,
|
|
400
|
+
};
|
|
279
401
|
}
|
|
280
402
|
if (currentRoute === 'vector-model') {
|
|
281
|
-
const
|
|
282
|
-
return {
|
|
403
|
+
const discovered = catalog()?.embedding ?? [];
|
|
404
|
+
return {
|
|
405
|
+
kind: 'text',
|
|
406
|
+
title: 'Vector search',
|
|
407
|
+
label: 'Embedding model',
|
|
408
|
+
note: 'Model exposed by the embeddings endpoint.',
|
|
409
|
+
...suggestionField(
|
|
410
|
+
discovered,
|
|
411
|
+
vector().embeddingModel,
|
|
412
|
+
fallbackModels(llm().engine, 'embedding')[0] ?? '',
|
|
413
|
+
),
|
|
414
|
+
};
|
|
283
415
|
}
|
|
284
416
|
if (currentRoute === 'vector-rerank') {
|
|
285
417
|
return { kind: 'confirm', title: 'Vector search', message: 'Enable reranking?', yesLabel: 'Enable', noLabel: 'Skip' };
|
|
286
418
|
}
|
|
287
419
|
if (currentRoute === 'vector-rerank-model') {
|
|
288
|
-
const
|
|
289
|
-
return {
|
|
420
|
+
const discovered = catalog()?.rerank ?? [];
|
|
421
|
+
return {
|
|
422
|
+
kind: 'text',
|
|
423
|
+
title: 'Vector search',
|
|
424
|
+
label: 'Rerank model',
|
|
425
|
+
note: 'Leave reranking disabled if no rerank model is available.',
|
|
426
|
+
...suggestionField(discovered, vector().rerankerModel, 'BAAI/bge-reranker-v2-m3'),
|
|
427
|
+
};
|
|
290
428
|
}
|
|
291
429
|
if (currentRoute === 'unregister-confirm') {
|
|
292
430
|
const workspace = targetWorkspace();
|
|
@@ -313,14 +451,209 @@ export function SetupWizard(props: {
|
|
|
313
451
|
const s = step();
|
|
314
452
|
setError(null);
|
|
315
453
|
setInput((s as any).prefill ?? '');
|
|
454
|
+
setHighlight(-1);
|
|
316
455
|
const items = (s as any).items ?? (s as any).options?.map((label: string) => ({ label })) ?? [{ label: 'x' }];
|
|
317
456
|
let preferred = -1;
|
|
457
|
+
// La question de routage est reposée à chaque passage — c'est plus simple
|
|
458
|
+
// et plus honnête que de la mémoriser dans un champ dédié. On se contente
|
|
459
|
+
// de présélectionner ce que le wikirc déclare déjà.
|
|
318
460
|
if (route() === 'llm-provider' && llm().provider) {
|
|
319
|
-
preferred = PROVIDERS.findIndex(
|
|
461
|
+
preferred = PROVIDERS.findIndex(
|
|
462
|
+
(p) => normalizeProvider(p) === normalizeProvider(llm().provider),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (route() === 'llm-engine' && llm().engine) {
|
|
466
|
+
preferred = ENGINE_OPTIONS.findIndex(
|
|
467
|
+
(e) => normalizeEngine(e) === normalizeEngine(llm().engine),
|
|
468
|
+
);
|
|
320
469
|
}
|
|
321
470
|
setSelected(preferred >= 0 ? preferred : firstSelectableIndex(items));
|
|
322
471
|
});
|
|
323
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Catalogue découvert auprès du serveur ou de la gateway. Il ne sert qu'à
|
|
475
|
+
* préremplir : le champ texte reste la vérité, ce qui garde le wizard
|
|
476
|
+
* utilisable quand l'endpoint est injoignable ou quand le modèle voulu n'y
|
|
477
|
+
* figure pas.
|
|
478
|
+
*/
|
|
479
|
+
const [catalog, setCatalog] = createSignal<any>(null);
|
|
480
|
+
const [catalogError, setCatalogError] = createSignal<string | null>(null);
|
|
481
|
+
/** URL interrogée pendant que la découverte est en vol, sinon `null`. */
|
|
482
|
+
const [discovering, setDiscovering] = createSignal<string | null>(null);
|
|
483
|
+
/**
|
|
484
|
+
* Numéro de la découverte en cours.
|
|
485
|
+
*
|
|
486
|
+
* La découverte ne bloque plus l'étape suivante : une réponse tardive peut
|
|
487
|
+
* donc revenir alors que l'opérateur a déjà corrigé l'URL ou la clé et
|
|
488
|
+
* relancé une découverte. Sans ce compteur, la vieille réponse écraserait la
|
|
489
|
+
* neuve.
|
|
490
|
+
*/
|
|
491
|
+
let discoveryRun = 0;
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Lance la découverte **sans l'attendre**.
|
|
495
|
+
*
|
|
496
|
+
* C'était la vraie cause du gel : la saisie de la clé partait dans un
|
|
497
|
+
* `await` muet, et l'écran suivant n'apparaissait qu'une fois le réseau
|
|
498
|
+
* retombé. Un catalogue n'est pourtant que du préremplissage — le champ
|
|
499
|
+
* texte fait foi. L'étape s'affiche donc tout de suite, et la liste se
|
|
500
|
+
* remplit quand elle arrive.
|
|
501
|
+
*/
|
|
502
|
+
function startDiscovery(target?: {
|
|
503
|
+
provider?: string;
|
|
504
|
+
engine?: string;
|
|
505
|
+
baseUrl?: string;
|
|
506
|
+
apiKey?: string;
|
|
507
|
+
}) {
|
|
508
|
+
const source = target ?? llm();
|
|
509
|
+
const { provider, engine, baseUrl, apiKey } = source as any;
|
|
510
|
+
setCatalog(null);
|
|
511
|
+
setCatalogError(null);
|
|
512
|
+
if (!baseUrl) return;
|
|
513
|
+
discoveryRun += 1;
|
|
514
|
+
const run = discoveryRun;
|
|
515
|
+
setDiscovering(baseUrl);
|
|
516
|
+
|
|
517
|
+
const isGateway = normalizeProvider(provider) === 'ai-gateway';
|
|
518
|
+
// Un serveur direct expose un seul catalogue, non typé : interroger chat
|
|
519
|
+
// puis embeddings tapait deux fois la même URL avec les mêmes en-têtes,
|
|
520
|
+
// pour la même réponse — et payait deux fois le délai.
|
|
521
|
+
const promise = isGateway
|
|
522
|
+
? fetchGatewayCatalog(baseUrl, apiKey, {
|
|
523
|
+
// La liste plate arrive la première et suffit à choisir : elle est
|
|
524
|
+
// affichée tout de suite, puis remplacée par le catalogue typé
|
|
525
|
+
// pendant que l'opérateur lit ses options.
|
|
526
|
+
onPartial: (partial: any) => {
|
|
527
|
+
if (run === discoveryRun) setCatalog(partial);
|
|
528
|
+
},
|
|
529
|
+
})
|
|
530
|
+
: fetchServerCatalog(provider, baseUrl, apiKey, { engine });
|
|
531
|
+
|
|
532
|
+
promise
|
|
533
|
+
.then((found: any) => {
|
|
534
|
+
if (run !== discoveryRun) return;
|
|
535
|
+
if (!found.ok) setCatalogError(found.error ?? 'endpoint unreachable');
|
|
536
|
+
else setCatalog(found);
|
|
537
|
+
})
|
|
538
|
+
.catch((err) => {
|
|
539
|
+
if (run !== discoveryRun) return;
|
|
540
|
+
setCatalogError(err instanceof Error ? err.message : String(err));
|
|
541
|
+
})
|
|
542
|
+
.finally(() => {
|
|
543
|
+
if (run === discoveryRun) setDiscovering(null);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function modelCount(found: any) {
|
|
548
|
+
return new Set([...(found?.chat ?? []), ...(found?.embedding ?? []), ...(found?.rerank ?? [])]).size;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Bloc d'état de la découverte, rendu sous le champ de saisie.
|
|
553
|
+
*
|
|
554
|
+
* Il occupe la place laissée libre au milieu du dialogue, là où il n'y avait
|
|
555
|
+
* rien : ce qui est tenté, contre quelle URL, avec quel transport, et ce que
|
|
556
|
+
* ça a donné.
|
|
557
|
+
*/
|
|
558
|
+
const discoveryLines = createMemo<Array<{ text: string; fg: string }>>(() => {
|
|
559
|
+
const width = textWidth();
|
|
560
|
+
const rows: Array<{ text: string; fg: string }> = [];
|
|
561
|
+
// L'indentation est appliquée à chaque ligne : `wrapText` normalise les
|
|
562
|
+
// espaces, un préfixe passé dans le texte serait perdu sur la première.
|
|
563
|
+
const indent = ' ';
|
|
564
|
+
const push = (text: string, fg: string, maxLines = 3) => {
|
|
565
|
+
for (const line of wrapText(text, width - indent.length, maxLines)) {
|
|
566
|
+
rows.push({ text: `${indent}${line}`, fg });
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
const pending = discovering();
|
|
570
|
+
const partial = catalog();
|
|
571
|
+
if (pending && partial) {
|
|
572
|
+
// La liste plate est déjà utilisable ; seul le typage manque encore.
|
|
573
|
+
rows.push({ text: `✓ ${modelCount(partial)} model(s) available`, fg: '#8BD5CA' });
|
|
574
|
+
rows.push({ text: '⟳ Refining chat/embedding/rerank types…', fg: '#FBBF24' });
|
|
575
|
+
return rows;
|
|
576
|
+
}
|
|
577
|
+
if (pending) {
|
|
578
|
+
rows.push({ text: '⟳ Reading the model catalog…', fg: '#FBBF24' });
|
|
579
|
+
push(pending, '#7F8C8D', 2);
|
|
580
|
+
push(transportSummary(), '#7F8C8D', 2);
|
|
581
|
+
return rows;
|
|
582
|
+
}
|
|
583
|
+
const failure = catalogError();
|
|
584
|
+
if (failure) {
|
|
585
|
+
rows.push({ text: '⚠ Model catalog unavailable', fg: '#FBBF24' });
|
|
586
|
+
push(`Cause: ${failure}`, '#F87171', 3);
|
|
587
|
+
push(`Transport: ${transportSummary()}`, '#7F8C8D', 2);
|
|
588
|
+
push('Type the model name below; it is used as-is.', '#7F8C8D', 2);
|
|
589
|
+
return rows;
|
|
590
|
+
}
|
|
591
|
+
const found = partial;
|
|
592
|
+
if (found) {
|
|
593
|
+
rows.push({ text: `✓ ${modelCount(found)} model(s) available`, fg: '#8BD5CA' });
|
|
594
|
+
push(
|
|
595
|
+
found.typed
|
|
596
|
+
? 'Typed catalog: chat, embedding and rerank lists are filtered.'
|
|
597
|
+
: 'Untyped catalog: the server does not say which model does what, so the three lists are identical.',
|
|
598
|
+
'#7F8C8D',
|
|
599
|
+
3,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
const stale = (step() as any).stale;
|
|
603
|
+
if (stale) {
|
|
604
|
+
push(`Configured "${stale}" is not in this catalog — pick one below.`, '#FBBF24', 2);
|
|
605
|
+
}
|
|
606
|
+
return rows;
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
/** Étapes de découverte : le bloc d'état n'a de sens que là. */
|
|
610
|
+
const DISCOVERY_ROUTES = new Set(['llm-apikey', 'llm-model', 'vector-model', 'vector-rerank-model']);
|
|
611
|
+
const showDiscoveryPanel = () => discovering() !== null || DISCOVERY_ROUTES.has(route());
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Position dans la phase courante, affichée en haut à droite.
|
|
615
|
+
*
|
|
616
|
+
* La séquence est reconstruite depuis les choix déjà faits, parce qu'elle
|
|
617
|
+
* est réellement variable : une gateway saute la question du moteur, un
|
|
618
|
+
* moteur hébergé saute l'URL, Ollama saute la clé. Annoncer un total fixe
|
|
619
|
+
* serait faux.
|
|
620
|
+
*/
|
|
621
|
+
function llmFlow() {
|
|
622
|
+
const provider = llm().provider;
|
|
623
|
+
const engine = llm().engine;
|
|
624
|
+
const steps = ['llm-provider'];
|
|
625
|
+
if (normalizeProvider(provider) !== 'ai-gateway') steps.push('llm-engine');
|
|
626
|
+
if (requiresBaseUrl(provider, engine)) steps.push('llm-baseurl');
|
|
627
|
+
if (normalizeProvider(provider) === 'ai-gateway' || normalizeEngine(engine) !== 'ollama') {
|
|
628
|
+
steps.push('llm-apikey');
|
|
629
|
+
}
|
|
630
|
+
steps.push('llm-model');
|
|
631
|
+
return steps;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function vectorFlow() {
|
|
635
|
+
const steps = ['vector-confirm', 'vector-baseurl', 'vector-apikey', 'vector-model', 'vector-rerank'];
|
|
636
|
+
if (vector().rerankEnabled) steps.push('vector-rerank-model');
|
|
637
|
+
return steps;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function progressLabel() {
|
|
641
|
+
const current = route();
|
|
642
|
+
for (const flow of [llmFlow(), vectorFlow()]) {
|
|
643
|
+
const index = flow.indexOf(current);
|
|
644
|
+
if (index >= 0) return `Step ${index + 1} of ${flow.length}`;
|
|
645
|
+
}
|
|
646
|
+
return '';
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Vrai quand l'URL vecteur ne pointe plus le même hôte que le LLM. */
|
|
650
|
+
function vectorBaseUrlDiverged() {
|
|
651
|
+
const vectorUrl = vector().baseUrl;
|
|
652
|
+
const llmUrl = llm().baseUrl;
|
|
653
|
+
if (!vectorUrl || !llmUrl) return false;
|
|
654
|
+
return vectorUrl.replace(/\/+$/, '') !== llmUrl.replace(/\/+$/, '');
|
|
655
|
+
}
|
|
656
|
+
|
|
324
657
|
function preloadWikirc(context: any) {
|
|
325
658
|
const workspacePath = context?.workspacePath;
|
|
326
659
|
if (!workspacePath) return;
|
|
@@ -330,6 +663,15 @@ export function SetupWizard(props: {
|
|
|
330
663
|
if (config?.llm?.provider) {
|
|
331
664
|
setLlm({
|
|
332
665
|
provider: normalizeProvider(config.llm.provider),
|
|
666
|
+
// Un wikirc pré-0.16 porte le moteur dans `provider`
|
|
667
|
+
// (`ollama`, `anthropic`, `openai`). Sans cette déduction, l'étape
|
|
668
|
+
// moteur ne présélectionne rien et propose OpenAI en tête — au
|
|
669
|
+
// risque d'écraser une configuration qui marchait.
|
|
670
|
+
engine: config.llm.engine
|
|
671
|
+
? normalizeEngine(config.llm.engine)
|
|
672
|
+
: normalizeProvider(config.llm.provider) === 'ai-gateway'
|
|
673
|
+
? null
|
|
674
|
+
: normalizeEngine(config.llm.provider),
|
|
333
675
|
baseUrl: configuredValue(config.llm.baseUrl),
|
|
334
676
|
apiKey: configuredValue(config.llm.apiKey),
|
|
335
677
|
model: configuredValue(config.llm.model),
|
|
@@ -342,9 +684,13 @@ export function SetupWizard(props: {
|
|
|
342
684
|
// URL the operator just entered, instead of the scaffold's fake one.
|
|
343
685
|
baseUrl: configuredValue(config.retrieval.vector.baseUrl),
|
|
344
686
|
apiKey: configuredValue(config.retrieval.vector.apiKey),
|
|
345
|
-
|
|
687
|
+
// Les noms de modèles étaient les deux seuls champs à échapper au
|
|
688
|
+
// filtre : le `BAAI/bge-m3` du scaffold arrivait donc dans le wizard
|
|
689
|
+
// comme une réponse choisie, et servait de filtre sur un catalogue
|
|
690
|
+
// qui nomme le même modèle autrement.
|
|
691
|
+
embeddingModel: configuredValue(config.retrieval.vector.embeddingModel),
|
|
346
692
|
rerankEnabled: config.retrieval.vector.rerankEnabled,
|
|
347
|
-
rerankerModel: config.retrieval.vector.rerankerModel,
|
|
693
|
+
rerankerModel: configuredValue(config.retrieval.vector.rerankerModel),
|
|
348
694
|
});
|
|
349
695
|
}
|
|
350
696
|
} catch { /* ignore — new workspace or unreadable profile */ }
|
|
@@ -500,14 +846,28 @@ export function SetupWizard(props: {
|
|
|
500
846
|
}
|
|
501
847
|
if (currentRoute === 'llm-provider') {
|
|
502
848
|
const provider = normalizeProvider(value);
|
|
849
|
+
setLlm((old: any) => ({
|
|
850
|
+
...old,
|
|
851
|
+
provider,
|
|
852
|
+
// Derrière une gateway il n'y a pas de moteur : l'endpoint est opaque
|
|
853
|
+
// et chaque modèle peut en avoir un différent.
|
|
854
|
+
engine: provider === 'ai-gateway' ? null : old.engine,
|
|
855
|
+
baseUrl: old.provider === provider ? old.baseUrl : '',
|
|
856
|
+
}));
|
|
857
|
+
// La gateway exige toujours une baseUrl, et n'a pas de moteur à choisir.
|
|
858
|
+
if (provider === 'ai-gateway') return navigate('llm-baseurl');
|
|
859
|
+
return navigate('llm-engine');
|
|
860
|
+
}
|
|
861
|
+
if (currentRoute === 'llm-engine') {
|
|
862
|
+
const engine = normalizeEngine(value);
|
|
503
863
|
setLlm((old: any) => {
|
|
504
|
-
const asksForBaseUrl = provider
|
|
505
|
-
const baseUrl = (old.
|
|
864
|
+
const asksForBaseUrl = requiresBaseUrl(old.provider, engine);
|
|
865
|
+
const baseUrl = (old.engine === engine && old.baseUrl)
|
|
506
866
|
? old.baseUrl
|
|
507
|
-
: asksForBaseUrl ? '' : defaultBaseUrl(provider);
|
|
508
|
-
return { ...old,
|
|
867
|
+
: asksForBaseUrl ? '' : defaultBaseUrl(old.provider, engine);
|
|
868
|
+
return { ...old, engine, baseUrl, ...(engine === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
|
|
509
869
|
});
|
|
510
|
-
if (provider
|
|
870
|
+
if (requiresBaseUrl(llm().provider, engine)) return navigate('llm-baseurl');
|
|
511
871
|
return navigate('llm-apikey');
|
|
512
872
|
}
|
|
513
873
|
if (currentRoute === 'unregister-confirm') {
|
|
@@ -569,7 +929,7 @@ export function SetupWizard(props: {
|
|
|
569
929
|
setVector((old: any) => ({
|
|
570
930
|
...old,
|
|
571
931
|
provider: old.provider || llm().provider,
|
|
572
|
-
baseUrl: old.baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider),
|
|
932
|
+
baseUrl: old.baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider, llm().engine),
|
|
573
933
|
}));
|
|
574
934
|
return navigate('vector-baseurl');
|
|
575
935
|
}
|
|
@@ -583,6 +943,10 @@ export function SetupWizard(props: {
|
|
|
583
943
|
if (lang.length < 2) return setError('Please enter a 2-character language code (e.g. fr, en).');
|
|
584
944
|
const context = currentWorkspaceContext(props.session, currentGap()?.context ?? targetWorkspace());
|
|
585
945
|
if (context?.workspacePath) writeLanguageConfig(context.workspacePath, context.profileName ?? 'default', lang);
|
|
946
|
+
// Sans cette ligne, le récapitulatif continuait d'afficher la langue lue
|
|
947
|
+
// dans le scaffold au moment de la création du workspace (`en`) au lieu
|
|
948
|
+
// de celle qui vient d'être saisie et écrite.
|
|
949
|
+
setLanguage(lang);
|
|
586
950
|
navigate('llm-provider');
|
|
587
951
|
return;
|
|
588
952
|
}
|
|
@@ -612,24 +976,48 @@ export function SetupWizard(props: {
|
|
|
612
976
|
if (currentRoute === 'llm-baseurl') {
|
|
613
977
|
if (!value) return setError('Base URL is required.');
|
|
614
978
|
setLlm((old: any) => ({ ...old, baseUrl: value }));
|
|
615
|
-
|
|
979
|
+
// Ollama n'exige pas de clé : on peut découvrir tout de suite.
|
|
980
|
+
if (llm().engine === 'ollama') {
|
|
981
|
+
startDiscovery({ ...llm(), baseUrl: value });
|
|
982
|
+
return navigate('llm-model');
|
|
983
|
+
}
|
|
616
984
|
return navigate('llm-apikey');
|
|
617
985
|
}
|
|
618
986
|
if (currentRoute === 'llm-apikey') {
|
|
619
987
|
if (!value) return setError('API key is required.');
|
|
620
988
|
setLlm((old: any) => ({ ...old, apiKey: value }));
|
|
989
|
+
// Pas de `await` : l'étape modèle s'affiche immédiatement et la liste
|
|
990
|
+
// s'y remplit toute seule.
|
|
991
|
+
startDiscovery({ ...llm(), apiKey: value });
|
|
621
992
|
return navigate('llm-model');
|
|
622
993
|
}
|
|
623
994
|
if (currentRoute === 'vector-baseurl') {
|
|
624
995
|
const baseUrl = value || vector().baseUrl || llm().baseUrl;
|
|
625
996
|
if (!baseUrl) return setError('Embeddings/rerank base URL is required.');
|
|
626
|
-
setVector((old: any) => ({
|
|
997
|
+
setVector((old: any) => ({
|
|
998
|
+
...old,
|
|
999
|
+
provider: llm().provider,
|
|
1000
|
+
engine: llm().engine,
|
|
1001
|
+
baseUrl,
|
|
1002
|
+
}));
|
|
627
1003
|
return navigate('vector-apikey');
|
|
628
1004
|
}
|
|
629
1005
|
if (currentRoute === 'vector-apikey') {
|
|
1006
|
+
if (vectorBaseUrlDiverged() && !value) {
|
|
1007
|
+
return setError(
|
|
1008
|
+
'API key is required: the vector base URL differs from the LLM one, so the LLM key is not reused.',
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
630
1011
|
const apiKey = value || llm().apiKey || undefined;
|
|
631
1012
|
if (!apiKey) return setError('API key is required (or set LLM key first).');
|
|
632
1013
|
setVector((old: any) => ({ ...old, apiKey }));
|
|
1014
|
+
// Le catalogue affiché aux étapes embeddings et rerank doit venir de
|
|
1015
|
+
// l'endpoint vecteur, pas du LLM : ce sont deux serveurs distincts dès
|
|
1016
|
+
// que l'URL diverge, et proposer les modèles de chat de l'un pour les
|
|
1017
|
+
// embeddings de l'autre n'a aucun sens.
|
|
1018
|
+
if (vectorBaseUrlDiverged()) {
|
|
1019
|
+
startDiscovery({ ...vector(), apiKey });
|
|
1020
|
+
}
|
|
633
1021
|
return navigate('vector-model');
|
|
634
1022
|
}
|
|
635
1023
|
if (currentRoute === 'llm-model') {
|
|
@@ -671,6 +1059,8 @@ export function SetupWizard(props: {
|
|
|
671
1059
|
});
|
|
672
1060
|
|
|
673
1061
|
useKeyboard((key: any) => {
|
|
1062
|
+
// La découverte ne bloque volontairement rien : on peut taper le nom du
|
|
1063
|
+
// modèle et valider avant même que le catalogue n'arrive.
|
|
674
1064
|
if (busy()) return;
|
|
675
1065
|
const s = step();
|
|
676
1066
|
const keyName = String(key.name ?? '').toLowerCase();
|
|
@@ -716,13 +1106,33 @@ export function SetupWizard(props: {
|
|
|
716
1106
|
const closeIdx = pasted.indexOf('\x1b[201~');
|
|
717
1107
|
if (closeIdx !== -1) pasted = pasted.slice(0, closeIdx);
|
|
718
1108
|
pasted = pasted.split('\r').join('');
|
|
719
|
-
if (pasted) setInput((value) => value + pasted);
|
|
1109
|
+
if (pasted) { setHighlight(-1); setInput((value) => value + pasted); }
|
|
720
1110
|
return;
|
|
721
1111
|
}
|
|
722
1112
|
// Explicit clipboard paste (Ctrl+V or Cmd+V on macOS)
|
|
723
1113
|
if (isPaste) {
|
|
724
1114
|
const pasted = readClipboard();
|
|
725
|
-
if (pasted) setInput((value) => value + pasted);
|
|
1115
|
+
if (pasted) { setHighlight(-1); setInput((value) => value + pasted); }
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Navigation dans le catalogue. Le champ reste la vérité : les flèches
|
|
1120
|
+
// ne font que survoler, et il faut valider pour écrire dans le champ.
|
|
1121
|
+
const matches = filteredSuggestions().matches;
|
|
1122
|
+
if (matches.length > 0 && (keyName === 'down' || keyName === 'up')) {
|
|
1123
|
+
setHighlight((value) => {
|
|
1124
|
+
if (keyName === 'down') return value + 1 >= matches.length ? -1 : value + 1;
|
|
1125
|
+
return value <= -1 ? matches.length - 1 : value - 1;
|
|
1126
|
+
});
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
const hovered = highlight() >= 0 ? matches[highlight()] : null;
|
|
1130
|
+
// Deux temps voulus : le premier Enter dépose le modèle survolé dans le
|
|
1131
|
+
// champ, le second valide l'étape. On peut donc relire, corriger ou
|
|
1132
|
+
// compléter ce qu'on vient de choisir.
|
|
1133
|
+
if (hovered && (isEnter || keyName === 'tab')) {
|
|
1134
|
+
setInput(hovered);
|
|
1135
|
+
setHighlight(-1);
|
|
726
1136
|
return;
|
|
727
1137
|
}
|
|
728
1138
|
if (isEnter) {
|
|
@@ -730,10 +1140,12 @@ export function SetupWizard(props: {
|
|
|
730
1140
|
return;
|
|
731
1141
|
}
|
|
732
1142
|
if (keyName === 'backspace') {
|
|
1143
|
+
setHighlight(-1);
|
|
733
1144
|
setInput((value) => value.slice(0, -1));
|
|
734
1145
|
return;
|
|
735
1146
|
}
|
|
736
1147
|
if (sequence.length >= 1 && !sequence.startsWith('\x1b') && sequence >= ' ') {
|
|
1148
|
+
setHighlight(-1);
|
|
737
1149
|
setInput((value) => value + sequence);
|
|
738
1150
|
}
|
|
739
1151
|
}
|
|
@@ -750,10 +1162,50 @@ export function SetupWizard(props: {
|
|
|
750
1162
|
const displayValue = () => {
|
|
751
1163
|
const s = step();
|
|
752
1164
|
if (s.kind !== 'text') return '';
|
|
753
|
-
|
|
754
|
-
|
|
1165
|
+
// Le champ vide affiche son indication en grisé, quel que soit le type
|
|
1166
|
+
// d'étape : c'est ce qui permet de ne plus préremplir un modèle arbitraire
|
|
1167
|
+
// tout en montrant à quoi ressemble une réponse valable.
|
|
1168
|
+
return input() || (s.placeholder ?? '');
|
|
755
1169
|
};
|
|
756
1170
|
const inputHasValue = () => step().kind === 'text' && input().length > 0;
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Suggestions filtrées par ce qui est tapé.
|
|
1174
|
+
*
|
|
1175
|
+
* Une gateway correctement remplie expose plusieurs centaines de modèles :
|
|
1176
|
+
* une liste brute est inutilisable, et un select classique interdirait de
|
|
1177
|
+
* saisir un modèle absent du catalogue. On garde donc le champ texte comme
|
|
1178
|
+
* seule vérité et on n'affiche qu'un rappel filtré — ce qui règle d'un coup
|
|
1179
|
+
* les trois cas : liste énorme, modèle absent, endpoint injoignable.
|
|
1180
|
+
*/
|
|
1181
|
+
const SUGGESTION_ROWS = 6;
|
|
1182
|
+
const filteredSuggestions = createMemo(() => {
|
|
1183
|
+
const current = step() as any;
|
|
1184
|
+
const all: string[] = current?.suggestions ?? [];
|
|
1185
|
+
if (all.length === 0) {
|
|
1186
|
+
return { matches: [] as string[], rows: [] as string[], offset: 0, total: 0, matched: 0 };
|
|
1187
|
+
}
|
|
1188
|
+
const needle = input().trim().toLowerCase();
|
|
1189
|
+
const matches = needle
|
|
1190
|
+
? all.filter((item) => item.toLowerCase().includes(needle))
|
|
1191
|
+
: all;
|
|
1192
|
+
|
|
1193
|
+
// Fenêtre glissante autour de l'élément survolé : sans elle, seules les
|
|
1194
|
+
// premières entrées étaient atteignables et un catalogue de 200 modèles
|
|
1195
|
+
// restait invisible au delà de la sixième ligne.
|
|
1196
|
+
const cursor = highlight();
|
|
1197
|
+
const offset =
|
|
1198
|
+
cursor < SUGGESTION_ROWS
|
|
1199
|
+
? 0
|
|
1200
|
+
: Math.min(cursor - SUGGESTION_ROWS + 1, Math.max(0, matches.length - SUGGESTION_ROWS));
|
|
1201
|
+
return {
|
|
1202
|
+
matches,
|
|
1203
|
+
rows: matches.slice(offset, offset + SUGGESTION_ROWS),
|
|
1204
|
+
offset,
|
|
1205
|
+
total: all.length,
|
|
1206
|
+
matched: matches.length,
|
|
1207
|
+
};
|
|
1208
|
+
});
|
|
757
1209
|
const lineWidth = () => Math.max(10, dialogWidth() - 10);
|
|
758
1210
|
const displayLine1 = () => displayValue().slice(0, lineWidth());
|
|
759
1211
|
const displayLine2 = () => displayValue().slice(lineWidth(), lineWidth() * 2);
|
|
@@ -762,20 +1214,42 @@ export function SetupWizard(props: {
|
|
|
762
1214
|
const showLine3 = () => displayValue().length > lineWidth() * 2;
|
|
763
1215
|
const contextPath = () => targetWorkspace()?.workspacePath ?? (currentGap()?.context?.workspacePath ?? null);
|
|
764
1216
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
if (
|
|
777
|
-
|
|
778
|
-
|
|
1217
|
+
/**
|
|
1218
|
+
* Récapitulatif de bas de cadre, en lignes `étiquette valeur`.
|
|
1219
|
+
*
|
|
1220
|
+
* Il était concaténé en une seule ligne, coupée net dès le deuxième champ :
|
|
1221
|
+
* le chemin du workspace mangeait la place, et l'URL — l'information qu'on
|
|
1222
|
+
* vient justement de saisir — n'apparaissait jamais en entier.
|
|
1223
|
+
*/
|
|
1224
|
+
const CONTEXT_LABEL_WIDTH = 11;
|
|
1225
|
+
const contextLines = createMemo(() => {
|
|
1226
|
+
const rows: Array<[string, string]> = [];
|
|
1227
|
+
const workspacePath = targetWorkspace()?.workspacePath ?? currentGap()?.context?.workspacePath ?? null;
|
|
1228
|
+
if (workspacePath) {
|
|
1229
|
+
rows.push(['Workspace', language() ? `${workspacePath} · lang ${language()}` : workspacePath]);
|
|
1230
|
+
}
|
|
1231
|
+
// Trois lignes au plus, et seulement celles de la phase en cours : ce
|
|
1232
|
+
// récapitulatif ne doit jamais pousser le pied de cadre hors de la boîte.
|
|
1233
|
+
if (route().startsWith('vector-')) {
|
|
1234
|
+
const vectorUrl = vector().baseUrl || llm().baseUrl;
|
|
1235
|
+
if (vectorUrl) rows.push(['Vector', vectorUrl]);
|
|
1236
|
+
if (vector().embeddingModel) rows.push(['Embedding', vector().embeddingModel]);
|
|
1237
|
+
} else {
|
|
1238
|
+
const routing = [llm().provider, llm().engine].filter(Boolean).join(' · ');
|
|
1239
|
+
if (routing) rows.push(['Routing', routing]);
|
|
1240
|
+
if (llm().baseUrl) {
|
|
1241
|
+
rows.push(['Endpoint', llm().apiKey ? `${llm().baseUrl} · key set` : llm().baseUrl]);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const width = textWidth() - CONTEXT_LABEL_WIDTH;
|
|
1246
|
+
const lines: string[] = [];
|
|
1247
|
+
for (const [label, value] of rows.slice(0, 3)) {
|
|
1248
|
+
wrapText(value, width, 2).forEach((line, index) => {
|
|
1249
|
+
lines.push(`${(index === 0 ? label : '').padEnd(CONTEXT_LABEL_WIDTH)}${line}`);
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
return lines;
|
|
779
1253
|
});
|
|
780
1254
|
|
|
781
1255
|
return (
|
|
@@ -794,17 +1268,34 @@ export function SetupWizard(props: {
|
|
|
794
1268
|
flexDirection="column"
|
|
795
1269
|
overflow="hidden"
|
|
796
1270
|
>
|
|
797
|
-
<
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1271
|
+
<Show when={logs().length > 0}>
|
|
1272
|
+
<For each={logs().slice(-3)}>
|
|
1273
|
+
{(entry) => <text height={1} fg={entry.icon === '✓' ? '#8BD5CA' : '#9CA3AF'}>{entry.icon} {entry.label}{entry.detail ? ` - ${entry.detail}` : ''}</text>}
|
|
1274
|
+
</For>
|
|
1275
|
+
<text height={1}>{''}</text>
|
|
1276
|
+
</Show>
|
|
1277
|
+
|
|
1278
|
+
{/* En-tête : phase à gauche, progression dans la phase à droite. */}
|
|
1279
|
+
<box height={1} flexDirection="row">
|
|
1280
|
+
<text fg="#8BD5CA">{stepTitle(step())}</text>
|
|
1281
|
+
<box flexGrow={1} />
|
|
1282
|
+
<text fg="#4B5563">{busy() ? 'working…' : progressLabel()}</text>
|
|
1283
|
+
</box>
|
|
1284
|
+
<text height={1} fg="#2A3441">{'─'.repeat(textWidth())}</text>
|
|
801
1285
|
<text height={1}>{''}</text>
|
|
1286
|
+
|
|
802
1287
|
<Show when={(step() as any).message || (step() as any).label}>
|
|
803
|
-
<
|
|
1288
|
+
<For each={wrapText((step() as any).message ?? (step() as any).label, textWidth(), 5)}>
|
|
1289
|
+
{(line) => <text height={1} fg="#D6DEE8">{line}</text>}
|
|
1290
|
+
</For>
|
|
804
1291
|
</Show>
|
|
805
1292
|
<Show when={(step() as any).note}>
|
|
806
|
-
<
|
|
1293
|
+
<For each={wrapText((step() as any).note, textWidth(), 4)}>
|
|
1294
|
+
{(line) => <text height={1} fg="#9CA3AF">{line}</text>}
|
|
1295
|
+
</For>
|
|
807
1296
|
</Show>
|
|
1297
|
+
<text height={1}>{''}</text>
|
|
1298
|
+
|
|
808
1299
|
<Show when={step().kind === 'text'}>
|
|
809
1300
|
<box
|
|
810
1301
|
height={5}
|
|
@@ -839,6 +1330,41 @@ export function SetupWizard(props: {
|
|
|
839
1330
|
</box>
|
|
840
1331
|
</box>
|
|
841
1332
|
</Show>
|
|
1333
|
+
<Show when={step().kind === 'text' && filteredSuggestions().total > 0}>
|
|
1334
|
+
<text height={1}>{''}</text>
|
|
1335
|
+
<For
|
|
1336
|
+
each={wrapText(
|
|
1337
|
+
filteredSuggestions().matched === 0
|
|
1338
|
+
? `No match among ${filteredSuggestions().total} model(s) — the typed value is used as-is.`
|
|
1339
|
+
: `${filteredSuggestions().matched} of ${filteredSuggestions().total} model(s) — ↑↓ to browse, Enter picks, the typed value wins.`,
|
|
1340
|
+
textWidth(),
|
|
1341
|
+
2,
|
|
1342
|
+
)}
|
|
1343
|
+
>
|
|
1344
|
+
{(line) => <text height={1} fg="#7F8C8D">{line}</text>}
|
|
1345
|
+
</For>
|
|
1346
|
+
<For each={filteredSuggestions().rows}>
|
|
1347
|
+
{(suggestion, index) => {
|
|
1348
|
+
const position = () => filteredSuggestions().offset + index();
|
|
1349
|
+
const hovered = () => position() === highlight();
|
|
1350
|
+
const chosen = () => suggestion === input().trim();
|
|
1351
|
+
return (
|
|
1352
|
+
<text
|
|
1353
|
+
height={1}
|
|
1354
|
+
fg={hovered() ? '#111318' : chosen() ? '#8BD5CA' : '#9CA3AF'}
|
|
1355
|
+
bg={hovered() ? '#8BD5CA' : '#111318'}
|
|
1356
|
+
>
|
|
1357
|
+
{`${hovered() ? ' > ' : chosen() ? ' ● ' : ' · '}${suggestion}`.slice(0, textWidth())}
|
|
1358
|
+
</text>
|
|
1359
|
+
);
|
|
1360
|
+
}}
|
|
1361
|
+
</For>
|
|
1362
|
+
<Show when={filteredSuggestions().matched > filteredSuggestions().rows.length}>
|
|
1363
|
+
<text height={1} fg="#7F8C8D">
|
|
1364
|
+
{` … ${filteredSuggestions().matched - filteredSuggestions().rows.length} more (↑↓ to scroll)`}
|
|
1365
|
+
</text>
|
|
1366
|
+
</Show>
|
|
1367
|
+
</Show>
|
|
842
1368
|
<Show when={step().kind !== 'text'}>
|
|
843
1369
|
<For each={currentItems()}>
|
|
844
1370
|
{(item, index) => (
|
|
@@ -847,25 +1373,50 @@ export function SetupWizard(props: {
|
|
|
847
1373
|
fg={(item as any).muted ? '#4B5563' : index() === selected() ? '#111318' : '#D6DEE8'}
|
|
848
1374
|
bg={index() === selected() && !(item as any).muted ? '#8BD5CA' : '#111318'}
|
|
849
1375
|
>
|
|
850
|
-
{(item as any).muted ? '
|
|
1376
|
+
{(item as any).muted ? ' ───' : `${index() === selected() ? '> ' : ' '}${item.label}`}
|
|
851
1377
|
</text>
|
|
852
1378
|
)}
|
|
853
1379
|
</For>
|
|
854
1380
|
</Show>
|
|
855
1381
|
|
|
1382
|
+
{/* État de la découverte : occupe l'espace laissé libre au milieu. */}
|
|
1383
|
+
<Show when={showDiscoveryPanel() && discoveryLines().length > 0}>
|
|
1384
|
+
<text height={1}>{''}</text>
|
|
1385
|
+
<For each={discoveryLines()}>
|
|
1386
|
+
{(row) => <text height={1} fg={row.fg}>{row.text}</text>}
|
|
1387
|
+
</For>
|
|
1388
|
+
</Show>
|
|
1389
|
+
|
|
856
1390
|
<Show when={error()}>
|
|
857
|
-
{(message) =>
|
|
1391
|
+
{(message) => (
|
|
1392
|
+
<>
|
|
1393
|
+
<text height={1}>{''}</text>
|
|
1394
|
+
<For each={wrapText(message(), textWidth(), 5)}>
|
|
1395
|
+
{(line) => <text height={1} fg="#F87171">{line}</text>}
|
|
1396
|
+
</For>
|
|
1397
|
+
</>
|
|
1398
|
+
)}
|
|
858
1399
|
</Show>
|
|
1400
|
+
|
|
859
1401
|
<box flexGrow={1} />
|
|
860
|
-
<Show when={
|
|
861
|
-
<text height={1} fg="#
|
|
1402
|
+
<Show when={contextLines().length > 0}>
|
|
1403
|
+
<text height={1} fg="#2A3441">{'─'.repeat(textWidth())}</text>
|
|
1404
|
+
<For each={contextLines()}>
|
|
1405
|
+
{(line) => <text height={1} fg="#4B5563">{line}</text>}
|
|
1406
|
+
</For>
|
|
862
1407
|
</Show>
|
|
863
1408
|
<text height={1}>{''}</text>
|
|
864
1409
|
<box height={1} flexDirection="row">
|
|
865
|
-
<text fg="#7F8C8D">
|
|
1410
|
+
<text fg="#7F8C8D">
|
|
1411
|
+
{step().kind !== 'text'
|
|
1412
|
+
? '↑↓ Move Enter Select Esc Skip'
|
|
1413
|
+
: filteredSuggestions().matched > 0
|
|
1414
|
+
? '↑↓ Browse Enter Pick / Confirm Esc Skip'
|
|
1415
|
+
: 'Enter Confirm Esc Skip'}
|
|
1416
|
+
</text>
|
|
866
1417
|
<box flexGrow={1} />
|
|
867
1418
|
<Show when={routeHistory().length > 0}>
|
|
868
|
-
<text fg="#7F8C8D">Ctrl+Z
|
|
1419
|
+
<text fg="#7F8C8D">Ctrl+Z Back</text>
|
|
869
1420
|
</Show>
|
|
870
1421
|
</box>
|
|
871
1422
|
</box>
|