@dotdrelle/wiki-manager 0.15.34 → 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 +241 -27
- package/src/core/modelFetch.test.js +213 -0
- package/src/core/profileServiceStatus.test.js +146 -0
- package/src/core/wikiSetup.js +11 -2
- package/src/core/wikiWorkspace.test.js +9 -3
- package/src/runtime/lifecycle.js +105 -3
- package/src/runtime/lifecycle.test.js +68 -0
- package/src/shell/SetupWizard.tsx +416 -116
- package/src/shell/repl.js +37 -11
- package/src/shell/setupWizardDiscovery.test.js +48 -0
- package/src/shell/setupWizardPlaceholders.test.js +15 -2
- package/src/shell/setupWizardSuggestions.test.js +36 -2
- package/src/shell/wrapText.js +57 -0
- package/src/shell/wrapText.test.js +48 -0
- package/wiki-workspace +115 -32
|
@@ -4,13 +4,15 @@ 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
6
|
import {
|
|
7
|
+
DISCOVERY_TIMEOUT_MS,
|
|
7
8
|
defaultBaseUrl,
|
|
8
9
|
fallbackModels,
|
|
9
10
|
fetchGatewayCatalog,
|
|
10
|
-
|
|
11
|
+
fetchServerCatalog,
|
|
11
12
|
normalizeEngine,
|
|
12
13
|
normalizeProvider,
|
|
13
14
|
requiresBaseUrl,
|
|
15
|
+
transportSummary,
|
|
14
16
|
} from '../core/modelFetch.js';
|
|
15
17
|
import { checkInternetConnectivity } from '../core/startupCheck.js';
|
|
16
18
|
import {
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
} from '../core/wikiSetup.js';
|
|
26
28
|
import { listWorkspaces, workspacesDir } from '../core/workspaces.js';
|
|
27
29
|
import { loadWikircProfile } from '../core/wikirc.js';
|
|
30
|
+
import { wrapText } from './wrapText.js';
|
|
28
31
|
|
|
29
32
|
type Gap = { kind: 'agents' | 'network' | 'workspace' | 'llm' | 'vector'; context?: Record<string, any> };
|
|
30
33
|
type Mode = 'startup' | 'setup';
|
|
@@ -46,6 +49,8 @@ type Step =
|
|
|
46
49
|
* le modèle voulu n'y figure pas.
|
|
47
50
|
*/
|
|
48
51
|
suggestions?: string[];
|
|
52
|
+
/** Valeur configurée écartée parce qu'absente du catalogue découvert. */
|
|
53
|
+
stale?: string | null;
|
|
49
54
|
}
|
|
50
55
|
| { kind: 'done' };
|
|
51
56
|
type LogEntry = { icon: string; label: string; detail?: string };
|
|
@@ -148,6 +153,35 @@ function stepTitle(step: Step) {
|
|
|
148
153
|
return step.kind === 'done' ? 'Setup complete' : step.title;
|
|
149
154
|
}
|
|
150
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
|
+
|
|
151
185
|
export function SetupWizard(props: {
|
|
152
186
|
mode: Mode;
|
|
153
187
|
session?: any;
|
|
@@ -166,6 +200,15 @@ export function SetupWizard(props: {
|
|
|
166
200
|
const [stepIndex, setStepIndex] = createSignal(0);
|
|
167
201
|
const [selected, setSelected] = createSignal(0);
|
|
168
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);
|
|
169
212
|
const [busy, setBusy] = createSignal(false);
|
|
170
213
|
const [error, setError] = createSignal<string | null>(null);
|
|
171
214
|
|
|
@@ -182,8 +225,14 @@ export function SetupWizard(props: {
|
|
|
182
225
|
});
|
|
183
226
|
|
|
184
227
|
const currentGap = () => startupGaps()[stepIndex()];
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
187
236
|
const left = () => Math.max(1, Math.floor((props.width - dialogWidth()) / 2));
|
|
188
237
|
const top = () => Math.max(1, Math.floor((props.height - dialogHeight()) / 2));
|
|
189
238
|
|
|
@@ -295,25 +344,38 @@ export function SetupWizard(props: {
|
|
|
295
344
|
return {
|
|
296
345
|
kind: 'text',
|
|
297
346
|
title: 'LLM configuration',
|
|
298
|
-
label: isGateway
|
|
299
|
-
|
|
300
|
-
|
|
347
|
+
label: isGateway ? 'Gateway base URL' : 'Base URL',
|
|
348
|
+
note: isGateway
|
|
349
|
+
? 'Example: http://gateway:4000/v1'
|
|
350
|
+
: example
|
|
351
|
+
? `Example: ${example}`
|
|
352
|
+
: undefined,
|
|
301
353
|
prefill: llm().baseUrl || '',
|
|
302
354
|
};
|
|
303
355
|
}
|
|
304
356
|
if (currentRoute === 'llm-apikey') {
|
|
305
|
-
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
|
+
};
|
|
306
364
|
}
|
|
307
365
|
if (currentRoute === 'llm-model') {
|
|
308
366
|
const discovered = catalog()?.chat ?? [];
|
|
309
|
-
const example = discovered[0] || fallbackModels(llm().engine)[0] || 'provider-agentic-model';
|
|
310
367
|
return {
|
|
311
368
|
kind: 'text',
|
|
312
369
|
title: 'LLM configuration',
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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',
|
|
374
|
+
note: 'Required: an agentic model with tool/function calling support.',
|
|
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'),
|
|
317
379
|
};
|
|
318
380
|
}
|
|
319
381
|
if (currentRoute === 'vector-confirm') {
|
|
@@ -339,18 +401,16 @@ export function SetupWizard(props: {
|
|
|
339
401
|
}
|
|
340
402
|
if (currentRoute === 'vector-model') {
|
|
341
403
|
const discovered = catalog()?.embedding ?? [];
|
|
342
|
-
const defaultEmbedding =
|
|
343
|
-
vector().embeddingModel ||
|
|
344
|
-
discovered[0] ||
|
|
345
|
-
fallbackModels(llm().engine, 'embedding')[0] ||
|
|
346
|
-
'';
|
|
347
404
|
return {
|
|
348
405
|
kind: 'text',
|
|
349
406
|
title: 'Vector search',
|
|
350
407
|
label: 'Embedding model',
|
|
351
|
-
note:
|
|
352
|
-
|
|
353
|
-
|
|
408
|
+
note: 'Model exposed by the embeddings endpoint.',
|
|
409
|
+
...suggestionField(
|
|
410
|
+
discovered,
|
|
411
|
+
vector().embeddingModel,
|
|
412
|
+
fallbackModels(llm().engine, 'embedding')[0] ?? '',
|
|
413
|
+
),
|
|
354
414
|
};
|
|
355
415
|
}
|
|
356
416
|
if (currentRoute === 'vector-rerank') {
|
|
@@ -358,15 +418,12 @@ export function SetupWizard(props: {
|
|
|
358
418
|
}
|
|
359
419
|
if (currentRoute === 'vector-rerank-model') {
|
|
360
420
|
const discovered = catalog()?.rerank ?? [];
|
|
361
|
-
const defaultReranker =
|
|
362
|
-
vector().rerankerModel || discovered[0] || 'BAAI/bge-reranker-v2-m3';
|
|
363
421
|
return {
|
|
364
422
|
kind: 'text',
|
|
365
423
|
title: 'Vector search',
|
|
366
424
|
label: 'Rerank model',
|
|
367
|
-
note:
|
|
368
|
-
|
|
369
|
-
suggestions: discovered,
|
|
425
|
+
note: 'Leave reranking disabled if no rerank model is available.',
|
|
426
|
+
...suggestionField(discovered, vector().rerankerModel, 'BAAI/bge-reranker-v2-m3'),
|
|
370
427
|
};
|
|
371
428
|
}
|
|
372
429
|
if (currentRoute === 'unregister-confirm') {
|
|
@@ -394,6 +451,7 @@ export function SetupWizard(props: {
|
|
|
394
451
|
const s = step();
|
|
395
452
|
setError(null);
|
|
396
453
|
setInput((s as any).prefill ?? '');
|
|
454
|
+
setHighlight(-1);
|
|
397
455
|
const items = (s as any).items ?? (s as any).options?.map((label: string) => ({ label })) ?? [{ label: 'x' }];
|
|
398
456
|
let preferred = -1;
|
|
399
457
|
// La question de routage est reposée à chaque passage — c'est plus simple
|
|
@@ -420,59 +478,172 @@ export function SetupWizard(props: {
|
|
|
420
478
|
*/
|
|
421
479
|
const [catalog, setCatalog] = createSignal<any>(null);
|
|
422
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;
|
|
423
492
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
async function discoverModels(target?: {
|
|
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?: {
|
|
436
503
|
provider?: string;
|
|
437
504
|
engine?: string;
|
|
438
505
|
baseUrl?: string;
|
|
439
506
|
apiKey?: string;
|
|
440
507
|
}) {
|
|
441
|
-
setCatalog(null);
|
|
442
|
-
setCatalogError(null);
|
|
443
508
|
const source = target ?? llm();
|
|
444
509
|
const { provider, engine, baseUrl, apiKey } = source as any;
|
|
510
|
+
setCatalog(null);
|
|
511
|
+
setCatalogError(null);
|
|
445
512
|
if (!baseUrl) return;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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);
|
|
460
544
|
});
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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 });
|
|
464
567
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
}
|
|
474
|
-
|
|
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');
|
|
475
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 '';
|
|
476
647
|
}
|
|
477
648
|
|
|
478
649
|
/** Vrai quand l'URL vecteur ne pointe plus le même hôte que le LLM. */
|
|
@@ -513,9 +684,13 @@ export function SetupWizard(props: {
|
|
|
513
684
|
// URL the operator just entered, instead of the scaffold's fake one.
|
|
514
685
|
baseUrl: configuredValue(config.retrieval.vector.baseUrl),
|
|
515
686
|
apiKey: configuredValue(config.retrieval.vector.apiKey),
|
|
516
|
-
|
|
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),
|
|
517
692
|
rerankEnabled: config.retrieval.vector.rerankEnabled,
|
|
518
|
-
rerankerModel: config.retrieval.vector.rerankerModel,
|
|
693
|
+
rerankerModel: configuredValue(config.retrieval.vector.rerankerModel),
|
|
519
694
|
});
|
|
520
695
|
}
|
|
521
696
|
} catch { /* ignore — new workspace or unreadable profile */ }
|
|
@@ -768,6 +943,10 @@ export function SetupWizard(props: {
|
|
|
768
943
|
if (lang.length < 2) return setError('Please enter a 2-character language code (e.g. fr, en).');
|
|
769
944
|
const context = currentWorkspaceContext(props.session, currentGap()?.context ?? targetWorkspace());
|
|
770
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);
|
|
771
950
|
navigate('llm-provider');
|
|
772
951
|
return;
|
|
773
952
|
}
|
|
@@ -799,7 +978,7 @@ export function SetupWizard(props: {
|
|
|
799
978
|
setLlm((old: any) => ({ ...old, baseUrl: value }));
|
|
800
979
|
// Ollama n'exige pas de clé : on peut découvrir tout de suite.
|
|
801
980
|
if (llm().engine === 'ollama') {
|
|
802
|
-
|
|
981
|
+
startDiscovery({ ...llm(), baseUrl: value });
|
|
803
982
|
return navigate('llm-model');
|
|
804
983
|
}
|
|
805
984
|
return navigate('llm-apikey');
|
|
@@ -807,7 +986,9 @@ export function SetupWizard(props: {
|
|
|
807
986
|
if (currentRoute === 'llm-apikey') {
|
|
808
987
|
if (!value) return setError('API key is required.');
|
|
809
988
|
setLlm((old: any) => ({ ...old, apiKey: value }));
|
|
810
|
-
await
|
|
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 });
|
|
811
992
|
return navigate('llm-model');
|
|
812
993
|
}
|
|
813
994
|
if (currentRoute === 'vector-baseurl') {
|
|
@@ -835,7 +1016,7 @@ export function SetupWizard(props: {
|
|
|
835
1016
|
// que l'URL diverge, et proposer les modèles de chat de l'un pour les
|
|
836
1017
|
// embeddings de l'autre n'a aucun sens.
|
|
837
1018
|
if (vectorBaseUrlDiverged()) {
|
|
838
|
-
|
|
1019
|
+
startDiscovery({ ...vector(), apiKey });
|
|
839
1020
|
}
|
|
840
1021
|
return navigate('vector-model');
|
|
841
1022
|
}
|
|
@@ -878,6 +1059,8 @@ export function SetupWizard(props: {
|
|
|
878
1059
|
});
|
|
879
1060
|
|
|
880
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.
|
|
881
1064
|
if (busy()) return;
|
|
882
1065
|
const s = step();
|
|
883
1066
|
const keyName = String(key.name ?? '').toLowerCase();
|
|
@@ -923,13 +1106,33 @@ export function SetupWizard(props: {
|
|
|
923
1106
|
const closeIdx = pasted.indexOf('\x1b[201~');
|
|
924
1107
|
if (closeIdx !== -1) pasted = pasted.slice(0, closeIdx);
|
|
925
1108
|
pasted = pasted.split('\r').join('');
|
|
926
|
-
if (pasted) setInput((value) => value + pasted);
|
|
1109
|
+
if (pasted) { setHighlight(-1); setInput((value) => value + pasted); }
|
|
927
1110
|
return;
|
|
928
1111
|
}
|
|
929
1112
|
// Explicit clipboard paste (Ctrl+V or Cmd+V on macOS)
|
|
930
1113
|
if (isPaste) {
|
|
931
1114
|
const pasted = readClipboard();
|
|
932
|
-
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);
|
|
933
1136
|
return;
|
|
934
1137
|
}
|
|
935
1138
|
if (isEnter) {
|
|
@@ -937,10 +1140,12 @@ export function SetupWizard(props: {
|
|
|
937
1140
|
return;
|
|
938
1141
|
}
|
|
939
1142
|
if (keyName === 'backspace') {
|
|
1143
|
+
setHighlight(-1);
|
|
940
1144
|
setInput((value) => value.slice(0, -1));
|
|
941
1145
|
return;
|
|
942
1146
|
}
|
|
943
1147
|
if (sequence.length >= 1 && !sequence.startsWith('\x1b') && sequence >= ' ') {
|
|
1148
|
+
setHighlight(-1);
|
|
944
1149
|
setInput((value) => value + sequence);
|
|
945
1150
|
}
|
|
946
1151
|
}
|
|
@@ -957,8 +1162,10 @@ export function SetupWizard(props: {
|
|
|
957
1162
|
const displayValue = () => {
|
|
958
1163
|
const s = step();
|
|
959
1164
|
if (s.kind !== 'text') return '';
|
|
960
|
-
|
|
961
|
-
|
|
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 ?? '');
|
|
962
1169
|
};
|
|
963
1170
|
const inputHasValue = () => step().kind === 'text' && input().length > 0;
|
|
964
1171
|
|
|
@@ -971,17 +1178,30 @@ export function SetupWizard(props: {
|
|
|
971
1178
|
* seule vérité et on n'affiche qu'un rappel filtré — ce qui règle d'un coup
|
|
972
1179
|
* les trois cas : liste énorme, modèle absent, endpoint injoignable.
|
|
973
1180
|
*/
|
|
974
|
-
const SUGGESTION_ROWS =
|
|
1181
|
+
const SUGGESTION_ROWS = 6;
|
|
975
1182
|
const filteredSuggestions = createMemo(() => {
|
|
976
1183
|
const current = step() as any;
|
|
977
1184
|
const all: string[] = current?.suggestions ?? [];
|
|
978
|
-
if (all.length === 0)
|
|
1185
|
+
if (all.length === 0) {
|
|
1186
|
+
return { matches: [] as string[], rows: [] as string[], offset: 0, total: 0, matched: 0 };
|
|
1187
|
+
}
|
|
979
1188
|
const needle = input().trim().toLowerCase();
|
|
980
1189
|
const matches = needle
|
|
981
1190
|
? all.filter((item) => item.toLowerCase().includes(needle))
|
|
982
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));
|
|
983
1201
|
return {
|
|
984
|
-
|
|
1202
|
+
matches,
|
|
1203
|
+
rows: matches.slice(offset, offset + SUGGESTION_ROWS),
|
|
1204
|
+
offset,
|
|
985
1205
|
total: all.length,
|
|
986
1206
|
matched: matches.length,
|
|
987
1207
|
};
|
|
@@ -994,20 +1214,42 @@ export function SetupWizard(props: {
|
|
|
994
1214
|
const showLine3 = () => displayValue().length > lineWidth() * 2;
|
|
995
1215
|
const contextPath = () => targetWorkspace()?.workspacePath ?? (currentGap()?.context?.workspacePath ?? null);
|
|
996
1216
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
if (
|
|
1009
|
-
|
|
1010
|
-
|
|
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;
|
|
1011
1253
|
});
|
|
1012
1254
|
|
|
1013
1255
|
return (
|
|
@@ -1026,17 +1268,34 @@ export function SetupWizard(props: {
|
|
|
1026
1268
|
flexDirection="column"
|
|
1027
1269
|
overflow="hidden"
|
|
1028
1270
|
>
|
|
1029
|
-
<
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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>
|
|
1033
1285
|
<text height={1}>{''}</text>
|
|
1286
|
+
|
|
1034
1287
|
<Show when={(step() as any).message || (step() as any).label}>
|
|
1035
|
-
<
|
|
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>
|
|
1036
1291
|
</Show>
|
|
1037
1292
|
<Show when={(step() as any).note}>
|
|
1038
|
-
<
|
|
1293
|
+
<For each={wrapText((step() as any).note, textWidth(), 4)}>
|
|
1294
|
+
{(line) => <text height={1} fg="#9CA3AF">{line}</text>}
|
|
1295
|
+
</For>
|
|
1039
1296
|
</Show>
|
|
1297
|
+
<text height={1}>{''}</text>
|
|
1298
|
+
|
|
1040
1299
|
<Show when={step().kind === 'text'}>
|
|
1041
1300
|
<box
|
|
1042
1301
|
height={5}
|
|
@@ -1072,21 +1331,37 @@ export function SetupWizard(props: {
|
|
|
1072
1331
|
</box>
|
|
1073
1332
|
</Show>
|
|
1074
1333
|
<Show when={step().kind === 'text' && filteredSuggestions().total > 0}>
|
|
1075
|
-
<text height={1}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
{` ${suggestion === input().trim() ? '*' : '·'} ${suggestion}`}
|
|
1084
|
-
</text>
|
|
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,
|
|
1085
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
|
+
}}
|
|
1086
1361
|
</For>
|
|
1087
|
-
<Show when={filteredSuggestions().matched >
|
|
1362
|
+
<Show when={filteredSuggestions().matched > filteredSuggestions().rows.length}>
|
|
1088
1363
|
<text height={1} fg="#7F8C8D">
|
|
1089
|
-
{`
|
|
1364
|
+
{` … ${filteredSuggestions().matched - filteredSuggestions().rows.length} more (↑↓ to scroll)`}
|
|
1090
1365
|
</text>
|
|
1091
1366
|
</Show>
|
|
1092
1367
|
</Show>
|
|
@@ -1098,25 +1373,50 @@ export function SetupWizard(props: {
|
|
|
1098
1373
|
fg={(item as any).muted ? '#4B5563' : index() === selected() ? '#111318' : '#D6DEE8'}
|
|
1099
1374
|
bg={index() === selected() && !(item as any).muted ? '#8BD5CA' : '#111318'}
|
|
1100
1375
|
>
|
|
1101
|
-
{(item as any).muted ? '
|
|
1376
|
+
{(item as any).muted ? ' ───' : `${index() === selected() ? '> ' : ' '}${item.label}`}
|
|
1102
1377
|
</text>
|
|
1103
1378
|
)}
|
|
1104
1379
|
</For>
|
|
1105
1380
|
</Show>
|
|
1106
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
|
+
|
|
1107
1390
|
<Show when={error()}>
|
|
1108
|
-
{(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
|
+
)}
|
|
1109
1399
|
</Show>
|
|
1400
|
+
|
|
1110
1401
|
<box flexGrow={1} />
|
|
1111
|
-
<Show when={
|
|
1112
|
-
<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>
|
|
1113
1407
|
</Show>
|
|
1114
1408
|
<text height={1}>{''}</text>
|
|
1115
1409
|
<box height={1} flexDirection="row">
|
|
1116
|
-
<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>
|
|
1117
1417
|
<box flexGrow={1} />
|
|
1118
1418
|
<Show when={routeHistory().length > 0}>
|
|
1119
|
-
<text fg="#7F8C8D">Ctrl+Z
|
|
1419
|
+
<text fg="#7F8C8D">Ctrl+Z Back</text>
|
|
1120
1420
|
</Show>
|
|
1121
1421
|
</box>
|
|
1122
1422
|
</box>
|