@dotdrelle/wiki-manager 0.15.29 → 0.15.34

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.
@@ -3,7 +3,15 @@ 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 { fallbackModels, normalizeProvider } from '../core/modelFetch.js';
6
+ import {
7
+ defaultBaseUrl,
8
+ fallbackModels,
9
+ fetchGatewayCatalog,
10
+ fetchModels,
11
+ normalizeEngine,
12
+ normalizeProvider,
13
+ requiresBaseUrl,
14
+ } from '../core/modelFetch.js';
7
15
  import { checkInternetConnectivity } from '../core/startupCheck.js';
8
16
  import {
9
17
  createNewWorkspace,
@@ -24,11 +32,40 @@ type Step =
24
32
  | { kind: 'menu'; title: string; items: Array<{ label: string; value: string; muted?: boolean }> }
25
33
  | { kind: 'confirm'; title: string; message: string; yesLabel: string; noLabel: string }
26
34
  | { kind: 'select'; title: string; label: string; options: string[]; note?: string }
27
- | { kind: 'text'; title: string; label: string; note?: string; placeholder?: string; prefill?: string; secret?: boolean }
35
+ | {
36
+ kind: 'text';
37
+ title: string;
38
+ label: string;
39
+ note?: string;
40
+ placeholder?: string;
41
+ prefill?: string;
42
+ secret?: boolean;
43
+ /**
44
+ * Catalogue découvert. Purement indicatif : le champ texte fait foi, ce
45
+ * qui garde l'étape utilisable quand l'endpoint est injoignable ou quand
46
+ * le modèle voulu n'y figure pas.
47
+ */
48
+ suggestions?: string[];
49
+ }
28
50
  | { kind: 'done' };
29
51
  type LogEntry = { icon: string; label: string; detail?: string };
30
52
 
31
- const PROVIDERS = ['OpenAI', 'Anthropic', 'Ollama (local)', 'Other (OpenAI-compatible)'];
53
+ // Deux axes, deux questions. `provider` dit où l'on tape, `engine` dit
54
+ // comment se comporte le serveur en face. Les fusionner était précisément ce
55
+ // qui empêchait de décrire une gateway.
56
+ const PROVIDERS = [
57
+ 'Direct server (OpenAI-compatible)',
58
+ 'AI gateway (LiteLLM, Bifrost, Portkey…)',
59
+ ];
60
+ const ENGINE_OPTIONS = [
61
+ 'OpenAI',
62
+ 'Anthropic',
63
+ 'Ollama (local)',
64
+ 'vLLM (local)',
65
+ 'MLX (local)',
66
+ 'Albert',
67
+ 'Other (generic OpenAI-compatible)',
68
+ ];
32
69
  // The scaffolded .wikirc.yaml ships fake endpoints and secrets so the file
33
70
  // documents its own shape (`https://mon-provider.example.com/v1`,
34
71
  // `http://infinity.local:7997/v1`, `YOUR_LLM_API_KEY`…). Preloading them into
@@ -43,12 +80,10 @@ function configuredValue(value: unknown) {
43
80
  }
44
81
  const MAIN_MENU = ['Agents', 'Workspaces', 'LLM configuration', 'Vector search', '---', 'Close'];
45
82
 
46
- function defaultBaseUrl(provider: string) {
47
- if (provider === 'ollama') return 'http://localhost:11434';
48
- if (provider === 'anthropic') return 'https://api.anthropic.com';
49
- if (provider === 'openai') return 'https://api.openai.com';
50
- if (provider === 'openai-compatible') return 'http://localhost:8000';
51
- return '';
83
+ // Les défauts et la question « faut-il demander une baseUrl ? » vivent dans
84
+ // core/modelFetch.js, source unique partagée avec la découverte.
85
+ function exampleBaseUrl(provider: string, engine: string) {
86
+ return defaultBaseUrl(provider, engine);
52
87
  }
53
88
 
54
89
  function currentWorkspaceContext(session: any, fallback?: any) {
@@ -239,17 +274,30 @@ export function SetupWizard(props: {
239
274
  kind: 'select',
240
275
  title: 'LLM configuration',
241
276
  label: context?.configError
242
- ? `${context.configError} Select a provider after creating or fixing the config:`
243
- : `No LLM configured${context?.workspaceName ? ` for ${context.workspaceName}` : ''}. Select a provider:`,
277
+ ? `${context.configError} Select how requests are routed after creating or fixing the config:`
278
+ : `No LLM configured${context?.workspaceName ? ` for ${context.workspaceName}` : ''}. How are requests routed?`,
244
279
  options: PROVIDERS,
280
+ note: 'A gateway is external infrastructure you deploy yourself; llm-wiki only reads its model catalog.',
281
+ };
282
+ }
283
+ if (currentRoute === 'llm-engine') {
284
+ return {
285
+ kind: 'select',
286
+ title: 'LLM configuration',
287
+ label: 'Which server is answering?',
288
+ options: ENGINE_OPTIONS,
289
+ note: 'Drives request-shaping workarounds and the `wiki doctor` calibration.',
245
290
  };
246
291
  }
247
292
  if (currentRoute === 'llm-baseurl') {
248
- const example = defaultBaseUrl(llm().provider);
293
+ const isGateway = llm().provider === 'ai-gateway';
294
+ const example = exampleBaseUrl(llm().provider, llm().engine);
249
295
  return {
250
296
  kind: 'text',
251
297
  title: 'LLM configuration',
252
- label: `Base URL${example ? ` (example: ${example})` : ''}`,
298
+ label: isGateway
299
+ ? 'Gateway base URL (example: http://gateway:4000/v1)'
300
+ : `Base URL${example ? ` (example: ${example})` : ''}`,
253
301
  prefill: llm().baseUrl || '',
254
302
  };
255
303
  }
@@ -257,13 +305,15 @@ export function SetupWizard(props: {
257
305
  return { kind: 'text', title: 'LLM configuration', label: 'API key (required)', secret: true };
258
306
  }
259
307
  if (currentRoute === 'llm-model') {
260
- const example = fallbackModels(llm().provider)[0] || 'provider-agentic-model';
308
+ const discovered = catalog()?.chat ?? [];
309
+ const example = discovered[0] || fallbackModels(llm().engine)[0] || 'provider-agentic-model';
261
310
  return {
262
311
  kind: 'text',
263
312
  title: 'LLM configuration',
264
313
  label: `Model (example: ${example})`,
265
- note: 'Required: an agentic model with tool/function calling support.',
266
- prefill: llm().model || '',
314
+ note: catalogNote('Required: an agentic model with tool/function calling support.'),
315
+ prefill: llm().model || example,
316
+ suggestions: discovered,
267
317
  };
268
318
  }
269
319
  if (currentRoute === 'vector-confirm') {
@@ -274,19 +324,50 @@ export function SetupWizard(props: {
274
324
  return { kind: 'text', title: 'Vector search', label: 'Embeddings/rerank base URL', prefill: baseUrl, placeholder: baseUrl };
275
325
  }
276
326
  if (currentRoute === 'vector-apikey') {
277
- const hint = llm().apiKey ? '(leave empty to reuse LLM key)' : undefined;
278
- return { kind: 'text', title: 'Vector search', label: 'Vector API key', placeholder: hint, secret: true };
327
+ // L'héritage n'est proposé que tant que l'URL n'a pas divergé : sinon la
328
+ // clé du LLM — celle de la gateway, qui ouvre tous les providers —
329
+ // partirait vers un autre hôte.
330
+ const diverged = vectorBaseUrlDiverged();
331
+ const hint = !diverged && llm().apiKey ? '(leave empty to reuse LLM key)' : undefined;
332
+ return {
333
+ kind: 'text',
334
+ title: 'Vector search',
335
+ label: diverged ? 'Vector API key (required: different host)' : 'Vector API key',
336
+ placeholder: hint,
337
+ secret: true,
338
+ };
279
339
  }
280
340
  if (currentRoute === 'vector-model') {
281
- const defaultEmbedding = vector().embeddingModel || fallbackModels(vector().provider || llm().provider, 'embedding')[0] || '';
282
- return { kind: 'text', title: 'Vector search', label: 'Embedding model', prefill: defaultEmbedding };
341
+ const discovered = catalog()?.embedding ?? [];
342
+ const defaultEmbedding =
343
+ vector().embeddingModel ||
344
+ discovered[0] ||
345
+ fallbackModels(llm().engine, 'embedding')[0] ||
346
+ '';
347
+ return {
348
+ kind: 'text',
349
+ title: 'Vector search',
350
+ label: 'Embedding model',
351
+ note: catalogNote('Embeddings endpoint model.'),
352
+ prefill: defaultEmbedding,
353
+ suggestions: discovered,
354
+ };
283
355
  }
284
356
  if (currentRoute === 'vector-rerank') {
285
357
  return { kind: 'confirm', title: 'Vector search', message: 'Enable reranking?', yesLabel: 'Enable', noLabel: 'Skip' };
286
358
  }
287
359
  if (currentRoute === 'vector-rerank-model') {
288
- const defaultReranker = vector().rerankerModel || 'BAAI/bge-reranker-v2-m3';
289
- return { kind: 'text', title: 'Vector search', label: 'Rerank model', prefill: defaultReranker };
360
+ const discovered = catalog()?.rerank ?? [];
361
+ const defaultReranker =
362
+ vector().rerankerModel || discovered[0] || 'BAAI/bge-reranker-v2-m3';
363
+ return {
364
+ kind: 'text',
365
+ title: 'Vector search',
366
+ label: 'Rerank model',
367
+ note: catalogNote('Leave reranking disabled if no rerank model is available.'),
368
+ prefill: defaultReranker,
369
+ suggestions: discovered,
370
+ };
290
371
  }
291
372
  if (currentRoute === 'unregister-confirm') {
292
373
  const workspace = targetWorkspace();
@@ -315,12 +396,93 @@ export function SetupWizard(props: {
315
396
  setInput((s as any).prefill ?? '');
316
397
  const items = (s as any).items ?? (s as any).options?.map((label: string) => ({ label })) ?? [{ label: 'x' }];
317
398
  let preferred = -1;
399
+ // La question de routage est reposée à chaque passage — c'est plus simple
400
+ // et plus honnête que de la mémoriser dans un champ dédié. On se contente
401
+ // de présélectionner ce que le wikirc déclare déjà.
318
402
  if (route() === 'llm-provider' && llm().provider) {
319
- preferred = PROVIDERS.findIndex((p) => normalizeProvider(p) === normalizeProvider(llm().provider));
403
+ preferred = PROVIDERS.findIndex(
404
+ (p) => normalizeProvider(p) === normalizeProvider(llm().provider),
405
+ );
406
+ }
407
+ if (route() === 'llm-engine' && llm().engine) {
408
+ preferred = ENGINE_OPTIONS.findIndex(
409
+ (e) => normalizeEngine(e) === normalizeEngine(llm().engine),
410
+ );
320
411
  }
321
412
  setSelected(preferred >= 0 ? preferred : firstSelectableIndex(items));
322
413
  });
323
414
 
415
+ /**
416
+ * Catalogue découvert auprès du serveur ou de la gateway. Il ne sert qu'à
417
+ * préremplir : le champ texte reste la vérité, ce qui garde le wizard
418
+ * utilisable quand l'endpoint est injoignable ou quand le modèle voulu n'y
419
+ * figure pas.
420
+ */
421
+ const [catalog, setCatalog] = createSignal<any>(null);
422
+ const [catalogError, setCatalogError] = createSignal<string | null>(null);
423
+
424
+ function catalogNote(base: string) {
425
+ const error = catalogError();
426
+ if (error) return `${base} (model discovery unavailable: ${error})`;
427
+ const found = catalog();
428
+ if (!found) return base;
429
+ if (!found.typed) {
430
+ return `${base} (gateway has no /model/info: the three lists are unfiltered)`;
431
+ }
432
+ return base;
433
+ }
434
+
435
+ async function discoverModels(target?: {
436
+ provider?: string;
437
+ engine?: string;
438
+ baseUrl?: string;
439
+ apiKey?: string;
440
+ }) {
441
+ setCatalog(null);
442
+ setCatalogError(null);
443
+ const source = target ?? llm();
444
+ const { provider, engine, baseUrl, apiKey } = source as any;
445
+ if (!baseUrl) return;
446
+ try {
447
+ if (normalizeProvider(provider) === 'ai-gateway') {
448
+ const found = await fetchGatewayCatalog(baseUrl, apiKey);
449
+ if (!found.ok) {
450
+ setCatalogError(found.error ?? 'gateway unreachable');
451
+ return;
452
+ }
453
+ setCatalog(found);
454
+ return;
455
+ }
456
+ const chat = await fetchModels(provider, baseUrl, apiKey, { engine });
457
+ const embedding = await fetchModels(provider, baseUrl, apiKey, {
458
+ engine,
459
+ kind: 'embedding',
460
+ });
461
+ if (!chat.ok && !embedding.ok) {
462
+ setCatalogError(chat.error ?? 'server unreachable');
463
+ return;
464
+ }
465
+ // Un serveur direct ne type pas ses modèles : les mêmes entrées
466
+ // alimentent les trois questions.
467
+ setCatalog({
468
+ typed: false,
469
+ chat: chat.models,
470
+ embedding: embedding.models,
471
+ rerank: embedding.models,
472
+ });
473
+ } catch (err) {
474
+ setCatalogError(err instanceof Error ? err.message : String(err));
475
+ }
476
+ }
477
+
478
+ /** Vrai quand l'URL vecteur ne pointe plus le même hôte que le LLM. */
479
+ function vectorBaseUrlDiverged() {
480
+ const vectorUrl = vector().baseUrl;
481
+ const llmUrl = llm().baseUrl;
482
+ if (!vectorUrl || !llmUrl) return false;
483
+ return vectorUrl.replace(/\/+$/, '') !== llmUrl.replace(/\/+$/, '');
484
+ }
485
+
324
486
  function preloadWikirc(context: any) {
325
487
  const workspacePath = context?.workspacePath;
326
488
  if (!workspacePath) return;
@@ -330,6 +492,15 @@ export function SetupWizard(props: {
330
492
  if (config?.llm?.provider) {
331
493
  setLlm({
332
494
  provider: normalizeProvider(config.llm.provider),
495
+ // Un wikirc pré-0.16 porte le moteur dans `provider`
496
+ // (`ollama`, `anthropic`, `openai`). Sans cette déduction, l'étape
497
+ // moteur ne présélectionne rien et propose OpenAI en tête — au
498
+ // risque d'écraser une configuration qui marchait.
499
+ engine: config.llm.engine
500
+ ? normalizeEngine(config.llm.engine)
501
+ : normalizeProvider(config.llm.provider) === 'ai-gateway'
502
+ ? null
503
+ : normalizeEngine(config.llm.provider),
333
504
  baseUrl: configuredValue(config.llm.baseUrl),
334
505
  apiKey: configuredValue(config.llm.apiKey),
335
506
  model: configuredValue(config.llm.model),
@@ -500,14 +671,28 @@ export function SetupWizard(props: {
500
671
  }
501
672
  if (currentRoute === 'llm-provider') {
502
673
  const provider = normalizeProvider(value);
674
+ setLlm((old: any) => ({
675
+ ...old,
676
+ provider,
677
+ // Derrière une gateway il n'y a pas de moteur : l'endpoint est opaque
678
+ // et chaque modèle peut en avoir un différent.
679
+ engine: provider === 'ai-gateway' ? null : old.engine,
680
+ baseUrl: old.provider === provider ? old.baseUrl : '',
681
+ }));
682
+ // La gateway exige toujours une baseUrl, et n'a pas de moteur à choisir.
683
+ if (provider === 'ai-gateway') return navigate('llm-baseurl');
684
+ return navigate('llm-engine');
685
+ }
686
+ if (currentRoute === 'llm-engine') {
687
+ const engine = normalizeEngine(value);
503
688
  setLlm((old: any) => {
504
- const asksForBaseUrl = provider === 'ollama' || provider === 'openai-compatible';
505
- const baseUrl = (old.provider === provider && old.baseUrl)
689
+ const asksForBaseUrl = requiresBaseUrl(old.provider, engine);
690
+ const baseUrl = (old.engine === engine && old.baseUrl)
506
691
  ? old.baseUrl
507
- : asksForBaseUrl ? '' : defaultBaseUrl(provider);
508
- return { ...old, provider, baseUrl, ...(provider === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
692
+ : asksForBaseUrl ? '' : defaultBaseUrl(old.provider, engine);
693
+ return { ...old, engine, baseUrl, ...(engine === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
509
694
  });
510
- if (provider === 'ollama' || provider === 'openai-compatible') return navigate('llm-baseurl');
695
+ if (requiresBaseUrl(llm().provider, engine)) return navigate('llm-baseurl');
511
696
  return navigate('llm-apikey');
512
697
  }
513
698
  if (currentRoute === 'unregister-confirm') {
@@ -569,7 +754,7 @@ export function SetupWizard(props: {
569
754
  setVector((old: any) => ({
570
755
  ...old,
571
756
  provider: old.provider || llm().provider,
572
- baseUrl: old.baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider),
757
+ baseUrl: old.baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider, llm().engine),
573
758
  }));
574
759
  return navigate('vector-baseurl');
575
760
  }
@@ -612,24 +797,46 @@ export function SetupWizard(props: {
612
797
  if (currentRoute === 'llm-baseurl') {
613
798
  if (!value) return setError('Base URL is required.');
614
799
  setLlm((old: any) => ({ ...old, baseUrl: value }));
615
- if (llm().provider === 'ollama') return navigate('llm-model');
800
+ // Ollama n'exige pas de clé : on peut découvrir tout de suite.
801
+ if (llm().engine === 'ollama') {
802
+ await discoverModels();
803
+ return navigate('llm-model');
804
+ }
616
805
  return navigate('llm-apikey');
617
806
  }
618
807
  if (currentRoute === 'llm-apikey') {
619
808
  if (!value) return setError('API key is required.');
620
809
  setLlm((old: any) => ({ ...old, apiKey: value }));
810
+ await discoverModels();
621
811
  return navigate('llm-model');
622
812
  }
623
813
  if (currentRoute === 'vector-baseurl') {
624
814
  const baseUrl = value || vector().baseUrl || llm().baseUrl;
625
815
  if (!baseUrl) return setError('Embeddings/rerank base URL is required.');
626
- setVector((old: any) => ({ ...old, provider: llm().provider, baseUrl }));
816
+ setVector((old: any) => ({
817
+ ...old,
818
+ provider: llm().provider,
819
+ engine: llm().engine,
820
+ baseUrl,
821
+ }));
627
822
  return navigate('vector-apikey');
628
823
  }
629
824
  if (currentRoute === 'vector-apikey') {
825
+ if (vectorBaseUrlDiverged() && !value) {
826
+ return setError(
827
+ 'API key is required: the vector base URL differs from the LLM one, so the LLM key is not reused.',
828
+ );
829
+ }
630
830
  const apiKey = value || llm().apiKey || undefined;
631
831
  if (!apiKey) return setError('API key is required (or set LLM key first).');
632
832
  setVector((old: any) => ({ ...old, apiKey }));
833
+ // Le catalogue affiché aux étapes embeddings et rerank doit venir de
834
+ // l'endpoint vecteur, pas du LLM : ce sont deux serveurs distincts dès
835
+ // que l'URL diverge, et proposer les modèles de chat de l'un pour les
836
+ // embeddings de l'autre n'a aucun sens.
837
+ if (vectorBaseUrlDiverged()) {
838
+ await discoverModels({ ...vector(), apiKey });
839
+ }
633
840
  return navigate('vector-model');
634
841
  }
635
842
  if (currentRoute === 'llm-model') {
@@ -754,6 +961,31 @@ export function SetupWizard(props: {
754
961
  return value || (s.secret ? (s.placeholder ?? '') : '');
755
962
  };
756
963
  const inputHasValue = () => step().kind === 'text' && input().length > 0;
964
+
965
+ /**
966
+ * Suggestions filtrées par ce qui est tapé.
967
+ *
968
+ * Une gateway correctement remplie expose plusieurs centaines de modèles :
969
+ * une liste brute est inutilisable, et un select classique interdirait de
970
+ * saisir un modèle absent du catalogue. On garde donc le champ texte comme
971
+ * seule vérité et on n'affiche qu'un rappel filtré — ce qui règle d'un coup
972
+ * les trois cas : liste énorme, modèle absent, endpoint injoignable.
973
+ */
974
+ const SUGGESTION_ROWS = 4;
975
+ const filteredSuggestions = createMemo(() => {
976
+ const current = step() as any;
977
+ const all: string[] = current?.suggestions ?? [];
978
+ if (all.length === 0) return { rows: [] as string[], total: 0, matched: 0 };
979
+ const needle = input().trim().toLowerCase();
980
+ const matches = needle
981
+ ? all.filter((item) => item.toLowerCase().includes(needle))
982
+ : all;
983
+ return {
984
+ rows: matches.slice(0, SUGGESTION_ROWS),
985
+ total: all.length,
986
+ matched: matches.length,
987
+ };
988
+ });
757
989
  const lineWidth = () => Math.max(10, dialogWidth() - 10);
758
990
  const displayLine1 = () => displayValue().slice(0, lineWidth());
759
991
  const displayLine2 = () => displayValue().slice(lineWidth(), lineWidth() * 2);
@@ -839,6 +1071,25 @@ export function SetupWizard(props: {
839
1071
  </box>
840
1072
  </box>
841
1073
  </Show>
1074
+ <Show when={step().kind === 'text' && filteredSuggestions().total > 0}>
1075
+ <text height={1} fg="#7F8C8D">
1076
+ {filteredSuggestions().matched === 0
1077
+ ? `no match among ${filteredSuggestions().total} discovered model(s) — the typed value is used as-is`
1078
+ : `${filteredSuggestions().matched}/${filteredSuggestions().total} discovered model(s) — type to filter, the typed value wins`}
1079
+ </text>
1080
+ <For each={filteredSuggestions().rows}>
1081
+ {(suggestion) => (
1082
+ <text height={1} fg={suggestion === input().trim() ? '#8BD5CA' : '#9CA3AF'}>
1083
+ {` ${suggestion === input().trim() ? '*' : '·'} ${suggestion}`}
1084
+ </text>
1085
+ )}
1086
+ </For>
1087
+ <Show when={filteredSuggestions().matched > SUGGESTION_ROWS}>
1088
+ <text height={1} fg="#7F8C8D">
1089
+ {` … ${filteredSuggestions().matched - SUGGESTION_ROWS} more`}
1090
+ </text>
1091
+ </Show>
1092
+ </Show>
842
1093
  <Show when={step().kind !== 'text'}>
843
1094
  <For each={currentItems()}>
844
1095
  {(item, index) => (
@@ -48,7 +48,7 @@ test('preloading a profile filters placeholders out of every prefilled field', (
48
48
  // With the vector URL filtered to null, this is what makes the embeddings
49
49
  // step offer the base URL the operator just typed.
50
50
  assert.match(source, /const baseUrl = vector\(\)\.baseUrl \|\| llm\(\)\.baseUrl;/);
51
- assert.match(source, /baseUrl: old\.baseUrl \|\| llm\(\)\.baseUrl \|\| defaultBaseUrl\(llm\(\)\.provider\)/);
51
+ assert.match(source, /baseUrl: old\.baseUrl \|\| llm\(\)\.baseUrl \|\| defaultBaseUrl\(llm\(\)\.provider, llm\(\)\.engine\)/);
52
52
  });
53
53
 
54
54
  test('a real configuration is never mistaken for a placeholder', () => {
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ const source = readFileSync(new URL('./SetupWizard.tsx', import.meta.url), 'utf8');
6
+
7
+ test('the model steps feed the discovered catalog into the filter', () => {
8
+ // Trois étapes doivent porter des suggestions : modèle LLM, embeddings,
9
+ // reranker. Sans elles, le filtre n'a rien a filtrer.
10
+ const occurrences = source.match(/suggestions: discovered/g) ?? [];
11
+ assert.equal(occurrences.length, 3);
12
+ });
13
+
14
+ test('the typed value always wins over the suggestion list', () => {
15
+ // Garde-fou : la liste ne doit jamais devenir un select. Si ce message
16
+ // disparait, c'est que la saisie libre a ete perdue.
17
+ assert.match(source, /the typed value wins/);
18
+ assert.match(source, /the typed value is used as-is/);
19
+ });
20
+
21
+ test('suggestions are filtered by the current input, not shown raw', () => {
22
+ assert.match(source, /all\.filter\(\(item\) => item\.toLowerCase\(\)\.includes\(needle\)\)/);
23
+ assert.match(source, /matches\.slice\(0, SUGGESTION_ROWS\)/);
24
+ });
package/src/shell/tui.tsx CHANGED
@@ -150,30 +150,22 @@ function App(props: {
150
150
  const [preflightBusy, setPreflightBusy] = createSignal(false);
151
151
  let ctrlCTimer: ReturnType<typeof setTimeout> | null = null;
152
152
  let exiting = false;
153
- // Single exit path: the owned-runtime shutdown MUST happen here, on the
154
- // user's actual exit gesture. render() resolves at MOUNT, so code placed
155
- // after `await runOpenTuiShell(...)` runs while the shell is still on
156
- // screen — 0.12.9 shipped that and killed the runtime mid-session.
153
+ // The runtime is shared infrastructure: `llm-wiki serve` can still be using
154
+ // it after this shell closes. Exiting the UI only destroys the renderer;
155
+ // explicit lifecycle commands own runtime shutdown.
157
156
  const exitShell = () => {
158
157
  if (exiting) return;
159
158
  exiting = true;
160
159
  setExitStatus('Fermeture enclenchée…');
161
- // Register the complete shutdown before destroying the renderer: onDestroy
162
- // resolves runOpenTuiShell, which must still await this cleanup task.
163
160
  const task = Promise.resolve().then(async () => {
164
161
  renderer.destroy();
165
- console.log('[wiki-manager] fermeture enclenchée — nettoyage en cours…');
166
- const messages: string[] = [];
167
- try {
168
- if (props.runtime?.url) {
169
- const { shutdownOwnedRuntime } = await import('../runtime/lifecycle.js');
170
- await shutdownOwnedRuntime(props.runtime, { log: (message: string) => { messages.push(message); } });
171
- }
172
- } catch {
173
- // Best effort: never block the exit on runtime cleanup.
174
- }
175
- for (const message of messages) console.log(`[wiki-manager] ${message}`);
176
- console.log('[wiki-manager] fermeture terminée — shell clos proprement.');
162
+ console.log('[wiki-manager] shell closed; shared runtime left running.');
163
+ // MCP clients, image-refresh probes, and reconnect timers may still own
164
+ // event-loop handles. They belong to this shell process, not to the
165
+ // detached shared runtime. Once the renderer is gone there is no useful
166
+ // foreground work left, so terminate explicitly instead of leaving the
167
+ // terminal pending indefinitely.
168
+ process.exit(0);
177
169
  });
178
170
  if (props.exitRef) props.exitRef.current = task;
179
171
  };
package/wiki-workspace CHANGED
@@ -84,6 +84,7 @@ Usage:
84
84
  wiki-workspace [--cacert <path>] <command> ...
85
85
  wiki-workspace config <workspace> [path]
86
86
  wiki-workspace up <workspace>
87
+ wiki-workspace refresh
87
88
  wiki-workspace wiki <workspace> <command> [args...]
88
89
  wiki-workspace list
89
90
 
@@ -91,6 +92,7 @@ Commands:
91
92
  config <workspace> [path] Create workspace folder, auto-select ports, run wiki init
92
93
  up <workspace> [--open] Start workspace stack: wiki + production
93
94
  list List configured workspaces
95
+ refresh Stop runtime/stacks and remove their Compose images
94
96
 
95
97
  agents up [--pull] Start all external agents (cme, documents)
96
98
  agents down Stop all external agents
@@ -260,6 +262,31 @@ agents_compose() {
260
262
  local compose_env_args=()
261
263
  [[ -f "$MANAGER_ENV_FILE" ]] && compose_env_args+=(--env-file "$MANAGER_ENV_FILE")
262
264
 
265
+ # Local-iteration build only: the packaged connectors service carries a
266
+ # `build:` block pointing at the sibling agent-connectors repo, whose OAuth
267
+ # credentials live in a gitignored .env.build.local. Compose resolves the
268
+ # value-less build args from *this* process environment, not from
269
+ # --env-file, so the variables must be exported here or the image ships with
270
+ # empty WIKILLM_GOOGLE_OAUTH_* and every Google connector stays unusable.
271
+ local connectors_build_env="$ROOT_DIR/../agent-external/agent-connectors/.env.build.local"
272
+ if [[ -f "$connectors_build_env" ]]; then
273
+ local build_key build_value
274
+ while IFS='=' read -r build_key build_value; do
275
+ case "$build_key" in
276
+ WIKILLM_GOOGLE_OAUTH_CLIENT_ID|WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET) ;;
277
+ *) continue ;;
278
+ esac
279
+ [[ -n "${!build_key:-}" ]] || export "$build_key=$build_value"
280
+ done < <(grep -v '^[[:space:]]*#' "$connectors_build_env" || true)
281
+ fi
282
+
283
+ require_connectors_build_credentials() {
284
+ local build_key
285
+ for build_key in WIKILLM_GOOGLE_OAUTH_CLIENT_ID WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET; do
286
+ [[ -n "${!build_key:-}" ]] || die "cannot build connectors: $build_key is missing from $connectors_build_env"
287
+ done
288
+ }
289
+
263
290
  _agents_dc() {
264
291
  local cacert_args=()
265
292
  local profile_args=()
@@ -288,6 +315,9 @@ agents_compose() {
288
315
  [[ $# -eq 0 ]] || die "agents up accepts only --pull"
289
316
  ensure_agent_tokens
290
317
  ensure_endpoints_file
318
+ if connectors_enabled && [[ -f "$ROOT_DIR/../agent-external/agent-connectors/Dockerfile" ]]; then
319
+ require_connectors_build_credentials
320
+ fi
291
321
  # The .env may have just been created above — recompute the env-file
292
322
  # flag so freshly generated tokens reach the containers on first run.
293
323
  compose_env_args=()
@@ -314,6 +344,10 @@ agents_compose() {
314
344
  [[ $# -eq 0 ]] || die "agents down does not take arguments"
315
345
  _agents_dc down
316
346
  ;;
347
+ purge)
348
+ [[ $# -eq 0 ]] || die "agents purge does not take arguments"
349
+ _agents_dc down --rmi all --remove-orphans
350
+ ;;
317
351
  logs)
318
352
  local log_args=()
319
353
  read_lines_into_array log_args logs_args "$@"
@@ -687,6 +721,19 @@ runtime_command() {
687
721
  ;;
688
722
  down|stop)
689
723
  [[ $# -eq 0 ]] || die "runtime down does not take arguments"
724
+ token="$(ensure_manager_runtime_token)"
725
+ if runtime_health_check; then
726
+ RUNTIME_URL="$(runtime_health_url)/shutdown" RUNTIME_TOKEN="$token" "$node_bin" --input-type=module -e \
727
+ "const h=process.env.RUNTIME_TOKEN?{Authorization:'Bearer '+process.env.RUNTIME_TOKEN}:{}; const r=await fetch(process.env.RUNTIME_URL,{method:'POST',headers:h}); process.exit(r.ok?0:1);"
728
+ local wait_count=0
729
+ while runtime_health_check && (( wait_count < 40 )); do
730
+ sleep 0.1
731
+ (( wait_count++ )) || true
732
+ done
733
+ rm -f "$pid_file"
734
+ printf 'agent-runtime stopped\n'
735
+ return
736
+ fi
690
737
  if ! runtime_pid_running; then
691
738
  printf 'agent-runtime is not running from %s\n' "$pid_file"
692
739
  rm -f "$pid_file"
@@ -731,6 +778,23 @@ runtime_command() {
731
778
  esac
732
779
  }
733
780
 
781
+ refresh_project() {
782
+ printf 'Stopping shared runtime...\n'
783
+ runtime_command down
784
+
785
+ local entry workspace
786
+ for entry in "$WORKSPACES_DIR"/*; do
787
+ [[ -f "$entry/.env" ]] || continue
788
+ workspace="$(basename "$entry")"
789
+ printf 'Removing workspace stack and images: %s\n' "$workspace"
790
+ compose_for_workspace "$workspace" down --rmi all --remove-orphans
791
+ done
792
+
793
+ printf 'Removing external-agent stack and images...\n'
794
+ agents_compose purge
795
+ printf 'Refresh complete. Persistent workspace and agent data were preserved.\n'
796
+ }
797
+
734
798
  ensure_runtime_up() {
735
799
  if [[ "${WIKI_MANAGER_RUNTIME_AUTOSTART:-1}" == "0" ]]; then
736
800
  return
@@ -1010,6 +1074,7 @@ cacert_compose_args() {
1010
1074
  run_wiki() {
1011
1075
  local workspace="$1"
1012
1076
  shift
1077
+ need_workspace_env "$workspace"
1013
1078
  local ws_path
1014
1079
  ws_path="$(normalize_path "$(workspace_value "$workspace" WIKI_WORKSPACE_PATH)")"
1015
1080
  ensure_workspace_dirs "$ws_path"
@@ -1214,6 +1279,11 @@ main() {
1214
1279
  exit 0
1215
1280
  fi
1216
1281
 
1282
+ if [[ $# -eq 1 && "$1" == "refresh" ]]; then
1283
+ refresh_project
1284
+ exit 0
1285
+ fi
1286
+
1217
1287
  # config <workspace> [path]
1218
1288
  if [[ $# -ge 2 && "$1" == "config" ]]; then
1219
1289
  local cfg_workspace="$2"