@dotdrelle/wiki-manager 0.15.32 → 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.
- package/package.json +2 -2
- package/src/core/buildInfo.json +1 -1
- package/src/core/mcp.js +1 -1
- package/src/core/modelFetch.js +229 -41
- package/src/core/modelFetch.test.js +77 -2
- package/src/core/wikiSetup.js +32 -2
- package/src/core/wikirc.test.js +59 -5
- package/src/shell/SetupWizard.tsx +282 -31
- package/src/shell/setupWizardPlaceholders.test.js +1 -1
- package/src/shell/setupWizardSuggestions.test.js +24 -0
- package/wiki-workspace +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.34",
|
|
4
4
|
"description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
|
|
5
5
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
6
|
"author": "dotrelle",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"start": "bun ./bin/wiki-manager.js",
|
|
14
|
-
"test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
|
|
14
|
+
"test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
|
|
15
15
|
"check-versions": "node scripts/check-versions.js",
|
|
16
16
|
"prepack": "node scripts/check-versions.js",
|
|
17
17
|
"prepublishOnly": "node scripts/check-versions.js",
|
package/src/core/buildInfo.json
CHANGED
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.15.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.34';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/modelFetch.js
CHANGED
|
@@ -1,56 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Découverte des modèles disponibles, pour alimenter le wizard.
|
|
3
|
+
*
|
|
4
|
+
* Deux chemins, correspondant aux deux valeurs de `llm.provider` :
|
|
5
|
+
*
|
|
6
|
+
* - `openai-compatible` : un serveur unique. L'endpoint et les en-têtes
|
|
7
|
+
* dépendent du moteur (`engine`), d'où les tables ci-dessous.
|
|
8
|
+
* - `ai-gateway` : un seul chemin, `GET /v1/models`, plus `GET /model/info`
|
|
9
|
+
* quand il est disponible — c'est lui qui porte le type de chaque modèle
|
|
10
|
+
* (chat, embedding, rerank) et permet de filtrer les listes du wizard.
|
|
11
|
+
*/
|
|
12
|
+
|
|
1
13
|
const FALLBACK_MODELS = {
|
|
2
14
|
openai: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-4.1', 'gpt-4.1-mini'],
|
|
3
15
|
anthropic: ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-3-7-sonnet-latest'],
|
|
4
16
|
ollama: ['llama3.2', 'qwen2.5', 'mistral', 'nomic-embed-text'],
|
|
5
|
-
|
|
6
|
-
|
|
17
|
+
vllm: ['Qwen/Qwen2.5-7B-Instruct', 'meta-llama/Llama-3.1-8B-Instruct'],
|
|
18
|
+
mlx: ['mlx-community/Qwen2.5-7B-Instruct-4bit'],
|
|
19
|
+
albert: ['albert-large', 'albert-small'],
|
|
20
|
+
generic: ['gpt-4.1-mini', 'llama3.2'],
|
|
7
21
|
};
|
|
8
22
|
|
|
9
23
|
const FALLBACK_EMBEDDINGS = {
|
|
10
24
|
openai: ['text-embedding-3-small', 'text-embedding-3-large'],
|
|
11
25
|
anthropic: ['text-embedding-3-small'],
|
|
12
26
|
ollama: ['nomic-embed-text', 'mxbai-embed-large'],
|
|
13
|
-
|
|
14
|
-
|
|
27
|
+
vllm: ['BAAI/bge-m3'],
|
|
28
|
+
mlx: ['BAAI/bge-m3'],
|
|
29
|
+
albert: ['BAAI/bge-m3'],
|
|
30
|
+
generic: ['BAAI/bge-m3', 'text-embedding-3-small', 'nomic-embed-text'],
|
|
15
31
|
};
|
|
16
32
|
|
|
33
|
+
export const PROVIDERS = ['openai-compatible', 'ai-gateway'];
|
|
34
|
+
|
|
35
|
+
export const ENGINES = [
|
|
36
|
+
'ollama',
|
|
37
|
+
'vllm',
|
|
38
|
+
'mlx',
|
|
39
|
+
'albert',
|
|
40
|
+
'openai',
|
|
41
|
+
'anthropic',
|
|
42
|
+
'generic',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** Moteurs qui exigent une baseUrl explicite — il n'existe pas de défaut sensé. */
|
|
46
|
+
const ENGINES_REQUIRING_BASE_URL = new Set(['ollama', 'vllm', 'mlx', 'generic']);
|
|
47
|
+
|
|
48
|
+
const ENGINE_DEFAULT_BASE_URL = {
|
|
49
|
+
openai: 'https://api.openai.com/v1',
|
|
50
|
+
anthropic: 'https://api.anthropic.com/v1',
|
|
51
|
+
albert: 'https://albert.api.etalab.gouv.fr/v1',
|
|
52
|
+
ollama: 'http://127.0.0.1:11434/v1',
|
|
53
|
+
vllm: 'http://127.0.0.1:8000/v1',
|
|
54
|
+
mlx: 'http://127.0.0.1:8080/v1',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function requiresBaseUrl(provider, engine) {
|
|
58
|
+
if (normalizeProvider(provider) === 'ai-gateway') return true;
|
|
59
|
+
return ENGINES_REQUIRING_BASE_URL.has(normalizeEngine(engine));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function defaultBaseUrl(provider, engine) {
|
|
63
|
+
if (normalizeProvider(provider) === 'ai-gateway') return '';
|
|
64
|
+
return ENGINE_DEFAULT_BASE_URL[normalizeEngine(engine)] ?? '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Routage. Tolérant aux libellés du wizard. */
|
|
17
68
|
export function normalizeProvider(provider) {
|
|
18
69
|
const value = String(provider ?? '').toLowerCase();
|
|
19
|
-
if (value.includes('
|
|
20
|
-
if (value.includes('anthropic')) return 'anthropic';
|
|
21
|
-
if (value.includes('ollama')) return 'ollama';
|
|
22
|
-
if (value.includes('openai')) return 'openai';
|
|
70
|
+
if (value.includes('gateway')) return 'ai-gateway';
|
|
23
71
|
return 'openai-compatible';
|
|
24
72
|
}
|
|
25
73
|
|
|
26
|
-
|
|
27
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Libellés du wizard vers moteur. Correspondance **exacte**, pas par sous-chaîne.
|
|
76
|
+
*
|
|
77
|
+
* Une recherche par sous-chaîne était fausse : « Other (generic
|
|
78
|
+
* OpenAI-compatible) » contient « openai », qui était testé avant « generic »
|
|
79
|
+
* — l'option « serveur générique » persistait donc `engine: openai`, avec les
|
|
80
|
+
* contournements inversés. Et aucun libellé ne pouvait plus résoudre vers
|
|
81
|
+
* `generic`, ce qui cassait la présélection à la réouverture du wizard.
|
|
82
|
+
*/
|
|
83
|
+
const ENGINE_LABELS = new Map([
|
|
84
|
+
['openai', 'openai'],
|
|
85
|
+
['anthropic', 'anthropic'],
|
|
86
|
+
['ollama (local)', 'ollama'],
|
|
87
|
+
['vllm (local)', 'vllm'],
|
|
88
|
+
['mlx (local)', 'mlx'],
|
|
89
|
+
['albert', 'albert'],
|
|
90
|
+
['other (generic openai-compatible)', 'generic'],
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Moteur. Accepte les libellés du wizard, les valeurs canoniques, et les
|
|
95
|
+
* anciennes valeurs de `provider` (`openai`, `ollama`, `anthropic`) devenues
|
|
96
|
+
* des moteurs.
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeEngine(engine) {
|
|
99
|
+
const value = String(engine ?? '').trim().toLowerCase();
|
|
100
|
+
const fromLabel = ENGINE_LABELS.get(value);
|
|
101
|
+
if (fromLabel) return fromLabel;
|
|
102
|
+
if (ENGINES.includes(value)) return value;
|
|
103
|
+
// Repli tolérant, utile pour les valeurs libres ; l'ordre importe donc les
|
|
104
|
+
// moteurs les plus spécifiques passent avant les plus génériques.
|
|
105
|
+
for (const candidate of ENGINES) {
|
|
106
|
+
if (candidate !== 'generic' && value.includes(candidate)) return candidate;
|
|
107
|
+
}
|
|
108
|
+
return 'generic';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fallbackFor(engine, kind) {
|
|
112
|
+
const normalized = normalizeEngine(engine);
|
|
28
113
|
const source = kind === 'embedding' ? FALLBACK_EMBEDDINGS : FALLBACK_MODELS;
|
|
29
|
-
return source[normalized] ?? source.
|
|
114
|
+
return source[normalized] ?? source.generic;
|
|
30
115
|
}
|
|
31
116
|
|
|
32
|
-
function
|
|
33
|
-
|
|
117
|
+
function trimUrl(url) {
|
|
118
|
+
return String(url ?? '').replace(/\/+$/g, '');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `baseUrl` est écrite avec son suffixe `/v1` dans le wikirc. Les endpoints de
|
|
123
|
+
* listing vivent tantôt sous `/v1` (OpenAI), tantôt à la racine (Ollama,
|
|
124
|
+
* `/model/info` de LiteLLM) — d'où cette racine sans suffixe.
|
|
125
|
+
*/
|
|
126
|
+
function rootOf(baseUrl) {
|
|
127
|
+
return trimUrl(baseUrl).replace(/\/v1$/, '');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function endpointFor(provider, engine, baseUrl) {
|
|
131
|
+
if (normalizeProvider(provider) === 'ai-gateway') {
|
|
132
|
+
return `${rootOf(baseUrl)}/v1/models`;
|
|
133
|
+
}
|
|
134
|
+
const normalized = normalizeEngine(engine);
|
|
34
135
|
if (normalized === 'anthropic') return 'https://api.anthropic.com/v1/models';
|
|
35
|
-
const root =
|
|
136
|
+
const root = rootOf(baseUrl) || 'https://api.openai.com';
|
|
36
137
|
return normalized === 'ollama' ? `${root}/api/tags` : `${root}/v1/models`;
|
|
37
138
|
}
|
|
38
139
|
|
|
39
|
-
function headersFor(provider, apiKey) {
|
|
40
|
-
|
|
140
|
+
function headersFor(provider, engine, apiKey) {
|
|
141
|
+
if (normalizeProvider(provider) === 'ai-gateway') {
|
|
142
|
+
return { Authorization: `Bearer ${apiKey}` };
|
|
143
|
+
}
|
|
144
|
+
const normalized = normalizeEngine(engine);
|
|
41
145
|
if (normalized === 'ollama') return {};
|
|
42
146
|
if (normalized === 'anthropic') {
|
|
43
|
-
return {
|
|
44
|
-
'x-api-key': apiKey,
|
|
45
|
-
'anthropic-version': '2023-06-01',
|
|
46
|
-
};
|
|
147
|
+
return { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
|
|
47
148
|
}
|
|
48
149
|
return { Authorization: `Bearer ${apiKey}` };
|
|
49
150
|
}
|
|
50
151
|
|
|
51
|
-
function parseModelNames(provider, payload) {
|
|
52
|
-
const
|
|
53
|
-
|
|
152
|
+
function parseModelNames(provider, engine, payload) {
|
|
153
|
+
const items =
|
|
154
|
+
normalizeProvider(provider) === 'openai-compatible' &&
|
|
155
|
+
normalizeEngine(engine) === 'ollama'
|
|
156
|
+
? payload?.models
|
|
157
|
+
: payload?.data;
|
|
54
158
|
if (!Array.isArray(items)) return [];
|
|
55
159
|
return items
|
|
56
160
|
.map((item) => item?.id ?? item?.name ?? item?.model)
|
|
@@ -59,39 +163,123 @@ function parseModelNames(provider, payload) {
|
|
|
59
163
|
.sort((a, b) => a.localeCompare(b));
|
|
60
164
|
}
|
|
61
165
|
|
|
166
|
+
async function getJson(url, headers, timeoutMs) {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
169
|
+
try {
|
|
170
|
+
const response = await fetch(url, { headers, signal: controller.signal });
|
|
171
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
172
|
+
return await response.json();
|
|
173
|
+
} finally {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Liste plate des modèles.
|
|
180
|
+
*
|
|
181
|
+
* `options.engine` porte le moteur ; à défaut, le premier argument est
|
|
182
|
+
* réinterprété comme tel, ce qui garde les appels historiques valides.
|
|
183
|
+
*/
|
|
62
184
|
export async function fetchModels(provider, baseUrl, apiKey, options = {}) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
185
|
+
const routing = normalizeProvider(provider);
|
|
186
|
+
const normalizedEngine = normalizeEngine(options.engine ?? provider);
|
|
187
|
+
|
|
188
|
+
if (routing === 'openai-compatible' && normalizedEngine === 'anthropic') {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
models: fallbackFor(normalizedEngine, options.kind),
|
|
192
|
+
source: 'fallback',
|
|
193
|
+
error: 'Anthropic model listing is not supported',
|
|
194
|
+
};
|
|
66
195
|
}
|
|
196
|
+
|
|
67
197
|
const timeoutMs = options.timeoutMs ?? 10000;
|
|
68
|
-
const controller = new AbortController();
|
|
69
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
70
198
|
try {
|
|
71
|
-
|
|
199
|
+
const needsKey = !(routing === 'openai-compatible' && normalizedEngine === 'ollama');
|
|
200
|
+
if (needsKey && !apiKey) {
|
|
72
201
|
throw new Error('API key is required to fetch remote models');
|
|
73
202
|
}
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
const models = parseModelNames(normalized, payload);
|
|
203
|
+
const payload = await getJson(
|
|
204
|
+
endpointFor(provider, normalizedEngine, baseUrl),
|
|
205
|
+
headersFor(provider, normalizedEngine, apiKey),
|
|
206
|
+
timeoutMs,
|
|
207
|
+
);
|
|
208
|
+
const models = parseModelNames(provider, normalizedEngine, payload);
|
|
81
209
|
if (models.length === 0) throw new Error('No models returned');
|
|
82
210
|
return { ok: true, models, source: 'remote' };
|
|
83
211
|
} catch (err) {
|
|
84
212
|
return {
|
|
85
213
|
ok: false,
|
|
86
|
-
models: fallbackFor(
|
|
214
|
+
models: fallbackFor(normalizedEngine, options.kind),
|
|
87
215
|
source: 'fallback',
|
|
88
216
|
error: err instanceof Error ? err.message : String(err),
|
|
89
217
|
};
|
|
90
|
-
} finally {
|
|
91
|
-
clearTimeout(timer);
|
|
92
218
|
}
|
|
93
219
|
}
|
|
94
220
|
|
|
95
|
-
|
|
96
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Catalogue typé d'une gateway.
|
|
223
|
+
*
|
|
224
|
+
* Dégradation gracieuse en trois temps — jamais un catch silencieux vers un
|
|
225
|
+
* défaut :
|
|
226
|
+
*
|
|
227
|
+
* 1. `GET /model/info` porte `model_info.mode` : on sait quel modèle est un
|
|
228
|
+
* chat, un embedding ou un reranker, et le wizard filtre ses listes.
|
|
229
|
+
* 2. `GET /v1/models` ne renvoie qu'une liste plate : les trois listes
|
|
230
|
+
* reçoivent la même chose, et `typed: false` permet à l'appelant de le
|
|
231
|
+
* dire à l'utilisateur.
|
|
232
|
+
* 3. Injoignable : listes vides, `error` renseignée. Le wizard garde sa
|
|
233
|
+
* saisie libre, qui fait foi de toute façon.
|
|
234
|
+
*/
|
|
235
|
+
export async function fetchGatewayCatalog(baseUrl, apiKey, options = {}) {
|
|
236
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
237
|
+
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const payload = await getJson(`${rootOf(baseUrl)}/model/info`, headers, timeoutMs);
|
|
241
|
+
const items = Array.isArray(payload?.data) ? payload.data : [];
|
|
242
|
+
const typed = { chat: [], embedding: [], rerank: [] };
|
|
243
|
+
for (const item of items) {
|
|
244
|
+
const name = item?.model_name ?? item?.id ?? item?.model_info?.id;
|
|
245
|
+
const mode = item?.model_info?.mode;
|
|
246
|
+
if (!name || !mode || !(mode in typed)) continue;
|
|
247
|
+
typed[mode].push(String(name));
|
|
248
|
+
}
|
|
249
|
+
const total = typed.chat.length + typed.embedding.length + typed.rerank.length;
|
|
250
|
+
if (total === 0) throw new Error('No typed models returned by /model/info');
|
|
251
|
+
for (const key of Object.keys(typed)) {
|
|
252
|
+
typed[key] = [...new Set(typed[key])].sort((a, b) => a.localeCompare(b));
|
|
253
|
+
}
|
|
254
|
+
return { ok: true, typed: true, source: 'model-info', ...typed };
|
|
255
|
+
} catch (modelInfoError) {
|
|
256
|
+
const flat = await fetchModels('ai-gateway', baseUrl, apiKey, { timeoutMs });
|
|
257
|
+
if (!flat.ok) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
typed: false,
|
|
261
|
+
source: 'unreachable',
|
|
262
|
+
chat: [],
|
|
263
|
+
embedding: [],
|
|
264
|
+
rerank: [],
|
|
265
|
+
error: flat.error,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
typed: false,
|
|
271
|
+
source: 'models',
|
|
272
|
+
chat: flat.models,
|
|
273
|
+
embedding: flat.models,
|
|
274
|
+
rerank: flat.models,
|
|
275
|
+
// Conservée pour l'affichage : elle explique pourquoi les listes ne sont
|
|
276
|
+
// pas filtrées.
|
|
277
|
+
error:
|
|
278
|
+
modelInfoError instanceof Error ? modelInfoError.message : String(modelInfoError),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function fallbackModels(engine, kind) {
|
|
284
|
+
return fallbackFor(engine, kind);
|
|
97
285
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
fallbackModels,
|
|
5
|
+
fetchGatewayCatalog,
|
|
6
|
+
fetchModels,
|
|
7
|
+
requiresBaseUrl,
|
|
8
|
+
} from './modelFetch.js';
|
|
4
9
|
|
|
5
10
|
test('fetchModels returns remote OpenAI-compatible model ids', async () => {
|
|
6
11
|
const originalFetch = globalThis.fetch;
|
|
@@ -34,5 +39,75 @@ test('fetchModels falls back on invalid remote response', async () => {
|
|
|
34
39
|
});
|
|
35
40
|
|
|
36
41
|
test('fallbackModels leaves custom-model to the wizard append action', () => {
|
|
37
|
-
assert.deepEqual(fallbackModels('
|
|
42
|
+
assert.deepEqual(fallbackModels('generic'), ['gpt-4.1-mini', 'llama3.2']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('fetchGatewayCatalog types models from /model/info', async () => {
|
|
46
|
+
const originalFetch = globalThis.fetch;
|
|
47
|
+
globalThis.fetch = async (url) => {
|
|
48
|
+
assert.equal(url, 'http://gw:4000/model/info');
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
json: async () => ({
|
|
52
|
+
data: [
|
|
53
|
+
{ model_name: 'anthropic/claude-sonnet-4-5', model_info: { mode: 'chat' } },
|
|
54
|
+
{ model_name: 'infinity/bge-m3', model_info: { mode: 'embedding' } },
|
|
55
|
+
{ model_name: 'infinity/bge-reranker', model_info: { mode: 'rerank' } },
|
|
56
|
+
{ model_name: 'dalle', model_info: { mode: 'image_generation' } },
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
|
|
63
|
+
assert.equal(result.ok, true);
|
|
64
|
+
assert.equal(result.typed, true);
|
|
65
|
+
assert.deepEqual(result.chat, ['anthropic/claude-sonnet-4-5']);
|
|
66
|
+
assert.deepEqual(result.embedding, ['infinity/bge-m3']);
|
|
67
|
+
assert.deepEqual(result.rerank, ['infinity/bge-reranker']);
|
|
68
|
+
} finally {
|
|
69
|
+
globalThis.fetch = originalFetch;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('fetchGatewayCatalog degrades to an untyped /v1/models list', async () => {
|
|
74
|
+
const originalFetch = globalThis.fetch;
|
|
75
|
+
globalThis.fetch = async (url) => {
|
|
76
|
+
if (url.endsWith('/model/info')) return { ok: false, status: 404 };
|
|
77
|
+
return { ok: true, json: async () => ({ data: [{ id: 'b' }, { id: 'a' }] }) };
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
|
|
81
|
+
assert.equal(result.ok, true);
|
|
82
|
+
assert.equal(result.typed, false);
|
|
83
|
+
// Non typé : les trois listes reçoivent la même chose, à charge du wizard
|
|
84
|
+
// de le signaler.
|
|
85
|
+
assert.deepEqual(result.chat, ['a', 'b']);
|
|
86
|
+
assert.deepEqual(result.embedding, ['a', 'b']);
|
|
87
|
+
assert.deepEqual(result.rerank, ['a', 'b']);
|
|
88
|
+
assert.match(result.error, /HTTP 404/);
|
|
89
|
+
} finally {
|
|
90
|
+
globalThis.fetch = originalFetch;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('fetchGatewayCatalog reports an unreachable gateway without inventing models', async () => {
|
|
95
|
+
const originalFetch = globalThis.fetch;
|
|
96
|
+
globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); };
|
|
97
|
+
try {
|
|
98
|
+
const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
|
|
99
|
+
assert.equal(result.ok, false);
|
|
100
|
+
assert.equal(result.source, 'unreachable');
|
|
101
|
+
assert.deepEqual(result.chat, []);
|
|
102
|
+
assert.match(result.error, /ECONNREFUSED/);
|
|
103
|
+
} finally {
|
|
104
|
+
globalThis.fetch = originalFetch;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('requiresBaseUrl follows the engine, and the gateway always needs one', () => {
|
|
109
|
+
assert.equal(requiresBaseUrl('openai-compatible', 'ollama'), true);
|
|
110
|
+
assert.equal(requiresBaseUrl('openai-compatible', 'openai'), false);
|
|
111
|
+
assert.equal(requiresBaseUrl('openai-compatible', 'anthropic'), false);
|
|
112
|
+
assert.equal(requiresBaseUrl('ai-gateway', 'openai'), true);
|
|
38
113
|
});
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -261,11 +261,37 @@ export function writeLanguageConfig(workspacePath, profileName, language) {
|
|
|
261
261
|
return patchWikircProfile(workspacePath, profileName || 'default', { language });
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Le wizard tourne sur l'hôte, les services dans Docker : une baseUrl saisie
|
|
266
|
+
* en `localhost` répond au wizard et échoue dans le container. On la réécrit
|
|
267
|
+
* vers `host.docker.internal`, que les services déclarent déjà en
|
|
268
|
+
* `extra_hosts`. Jamais sur un hostname réel — seulement sur les trois formes
|
|
269
|
+
* de boucle locale. L'appelant affiche la réécriture : la faire en silence
|
|
270
|
+
* rend le diagnostic impossible quand elle se trompe.
|
|
271
|
+
*/
|
|
272
|
+
export function containerReachableUrl(baseUrl) {
|
|
273
|
+
if (!baseUrl) return { url: baseUrl, rewritten: false };
|
|
274
|
+
try {
|
|
275
|
+
const parsed = new URL(baseUrl);
|
|
276
|
+
if (!['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname)) {
|
|
277
|
+
return { url: baseUrl, rewritten: false };
|
|
278
|
+
}
|
|
279
|
+
const original = parsed.hostname;
|
|
280
|
+
parsed.hostname = 'host.docker.internal';
|
|
281
|
+
return { url: parsed.toString().replace(/\/$/, ''), rewritten: true, from: original };
|
|
282
|
+
} catch {
|
|
283
|
+
return { url: baseUrl, rewritten: false };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
264
287
|
export function writeLlmConfig(workspacePath, profileName, config) {
|
|
265
288
|
const patches = {
|
|
266
289
|
llm: {
|
|
267
290
|
provider: config.provider,
|
|
268
|
-
|
|
291
|
+
// Absent derrière une gateway : l'endpoint est opaque, il n'y a pas un
|
|
292
|
+
// moteur mais un par modèle.
|
|
293
|
+
...(config.engine ? { engine: config.engine } : {}),
|
|
294
|
+
...(config.baseUrl ? { baseUrl: containerReachableUrl(config.baseUrl).url } : {}),
|
|
269
295
|
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
|
270
296
|
model: config.model,
|
|
271
297
|
},
|
|
@@ -278,7 +304,11 @@ export function writeVectorConfig(workspacePath, profileName, config) {
|
|
|
278
304
|
retrieval: {
|
|
279
305
|
vector: {
|
|
280
306
|
enabled: true,
|
|
281
|
-
|
|
307
|
+
// baseUrl et apiKey absents = hérités du bloc llm par resolveConfig.
|
|
308
|
+
// Le wizard ne les transmet que lorsqu'ils divergent réellement, pour
|
|
309
|
+
// que le wikirc reste lisible et que la clé du LLM ne soit pas
|
|
310
|
+
// recopiée vers un autre hôte.
|
|
311
|
+
...(config.baseUrl ? { baseUrl: containerReachableUrl(config.baseUrl).url } : {}),
|
|
282
312
|
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
|
283
313
|
timeoutMs: config.timeoutMs ?? 600_000,
|
|
284
314
|
embeddingModel: config.embeddingModel,
|
package/src/core/wikirc.test.js
CHANGED
|
@@ -5,7 +5,12 @@ import { join } from 'node:path';
|
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import YAML from 'yaml';
|
|
7
7
|
import { loadWikircProfile, normalizeCapabilityRouting, patchWikircProfile } from './wikirc.js';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
containerReachableUrl,
|
|
10
|
+
finalizeCreatedWorkspace,
|
|
11
|
+
writeLlmConfig,
|
|
12
|
+
writeVectorConfig,
|
|
13
|
+
} from './wikiSetup.js';
|
|
9
14
|
|
|
10
15
|
test('patchWikircProfile merges keys and preserves existing values', () => {
|
|
11
16
|
const root = mkdtempSync(join(tmpdir(), 'wikirc-patch-'));
|
|
@@ -106,7 +111,7 @@ test('writeVectorConfig writes llm-wiki vector and rerank keys', () => {
|
|
|
106
111
|
].join('\n'), 'utf8');
|
|
107
112
|
|
|
108
113
|
writeVectorConfig(root, 'default', {
|
|
109
|
-
baseUrl: 'http://
|
|
114
|
+
baseUrl: 'http://host.docker.internal:7997/v1',
|
|
110
115
|
apiKey: 'vector-key',
|
|
111
116
|
embeddingModel: 'BAAI/bge-m3',
|
|
112
117
|
rerankEnabled: true,
|
|
@@ -115,7 +120,7 @@ test('writeVectorConfig writes llm-wiki vector and rerank keys', () => {
|
|
|
115
120
|
|
|
116
121
|
const parsed = YAML.parse(readFileSync(join(root, '.wikirc.yaml'), 'utf8'));
|
|
117
122
|
assert.equal(parsed.retrieval.vector.enabled, true);
|
|
118
|
-
assert.equal(parsed.retrieval.vector.baseUrl, 'http://
|
|
123
|
+
assert.equal(parsed.retrieval.vector.baseUrl, 'http://host.docker.internal:7997/v1');
|
|
119
124
|
assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
|
|
120
125
|
assert.equal(parsed.retrieval.vector.embeddingModel, 'BAAI/bge-m3');
|
|
121
126
|
assert.equal(parsed.retrieval.vector.rerankEnabled, true);
|
|
@@ -143,7 +148,7 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
|
|
|
143
148
|
].join('\n'), 'utf8');
|
|
144
149
|
|
|
145
150
|
writeVectorConfig(root, 'default', {
|
|
146
|
-
baseUrl: 'http://
|
|
151
|
+
baseUrl: 'http://host.docker.internal:7997/v1',
|
|
147
152
|
apiKey: 'vector-key',
|
|
148
153
|
embeddingModel: 'BAAI/bge-m3',
|
|
149
154
|
rerankEnabled: true,
|
|
@@ -154,7 +159,7 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
|
|
|
154
159
|
const parsed = YAML.parse(raw);
|
|
155
160
|
assert.doesNotMatch(raw, /^\s*#\s*baseUrl:/m);
|
|
156
161
|
assert.doesNotMatch(raw, /^\s*#\s*apiKey:/m);
|
|
157
|
-
assert.equal(parsed.retrieval.vector.baseUrl, 'http://
|
|
162
|
+
assert.equal(parsed.retrieval.vector.baseUrl, 'http://host.docker.internal:7997/v1');
|
|
158
163
|
assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
|
|
159
164
|
});
|
|
160
165
|
|
|
@@ -195,3 +200,52 @@ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc',
|
|
|
195
200
|
else process.env.WIKI_WORKSPACES_DIR = previousDir;
|
|
196
201
|
}
|
|
197
202
|
});
|
|
203
|
+
|
|
204
|
+
test('writeLlmConfig writes provider + engine and rewrites loopback hosts for containers', () => {
|
|
205
|
+
const root = mkdtempSync(join(tmpdir(), 'wikirc-llm-'));
|
|
206
|
+
writeFileSync(join(root, '.wikirc.yaml'), ['language: en', ''].join('\n'), 'utf8');
|
|
207
|
+
|
|
208
|
+
writeLlmConfig(root, 'default', {
|
|
209
|
+
provider: 'openai-compatible',
|
|
210
|
+
engine: 'ollama',
|
|
211
|
+
baseUrl: 'http://localhost:11434/v1',
|
|
212
|
+
apiKey: 'ollama',
|
|
213
|
+
model: 'qwen2.5',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const parsed = YAML.parse(readFileSync(join(root, '.wikirc.yaml'), 'utf8'));
|
|
217
|
+
assert.equal(parsed.llm.provider, 'openai-compatible');
|
|
218
|
+
assert.equal(parsed.llm.engine, 'ollama');
|
|
219
|
+
// Le wizard tourne sur l'hôte, serve dans Docker : localhost y désigne le
|
|
220
|
+
// container lui-meme.
|
|
221
|
+
assert.equal(parsed.llm.baseUrl, 'http://host.docker.internal:11434/v1');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('writeLlmConfig omits engine behind a gateway and leaves real hostnames alone', () => {
|
|
225
|
+
const root = mkdtempSync(join(tmpdir(), 'wikirc-llm-gw-'));
|
|
226
|
+
writeFileSync(join(root, '.wikirc.yaml'), ['language: en', ''].join('\n'), 'utf8');
|
|
227
|
+
|
|
228
|
+
writeLlmConfig(root, 'default', {
|
|
229
|
+
provider: 'ai-gateway',
|
|
230
|
+
engine: null,
|
|
231
|
+
baseUrl: 'https://gateway.internal.example/v1',
|
|
232
|
+
apiKey: 'sk-virtual',
|
|
233
|
+
model: 'anthropic/claude-sonnet-4-5',
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const parsed = YAML.parse(readFileSync(join(root, '.wikirc.yaml'), 'utf8'));
|
|
237
|
+
assert.equal(parsed.llm.provider, 'ai-gateway');
|
|
238
|
+
assert.equal(parsed.llm.engine, undefined);
|
|
239
|
+
assert.equal(parsed.llm.baseUrl, 'https://gateway.internal.example/v1');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('containerReachableUrl only touches loopback hosts', () => {
|
|
243
|
+
assert.deepEqual(containerReachableUrl('http://127.0.0.1:8000/v1'), {
|
|
244
|
+
url: 'http://host.docker.internal:8000/v1',
|
|
245
|
+
rewritten: true,
|
|
246
|
+
from: '127.0.0.1',
|
|
247
|
+
});
|
|
248
|
+
assert.equal(containerReachableUrl('https://api.openai.com/v1').rewritten, false);
|
|
249
|
+
assert.equal(containerReachableUrl('http://gateway:4000/v1').rewritten, false);
|
|
250
|
+
assert.equal(containerReachableUrl(undefined).rewritten, false);
|
|
251
|
+
});
|
|
@@ -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 {
|
|
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
|
-
| {
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
243
|
-
: `No LLM configured${context?.workspaceName ? ` for ${context.workspaceName}` : ''}.
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
282
|
-
|
|
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
|
|
289
|
-
|
|
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(
|
|
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
|
|
505
|
-
const baseUrl = (old.
|
|
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,
|
|
692
|
+
: asksForBaseUrl ? '' : defaultBaseUrl(old.provider, engine);
|
|
693
|
+
return { ...old, engine, baseUrl, ...(engine === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
|
|
509
694
|
});
|
|
510
|
-
if (provider
|
|
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
|
-
|
|
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) => ({
|
|
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/wiki-workspace
CHANGED
|
@@ -1074,6 +1074,7 @@ cacert_compose_args() {
|
|
|
1074
1074
|
run_wiki() {
|
|
1075
1075
|
local workspace="$1"
|
|
1076
1076
|
shift
|
|
1077
|
+
need_workspace_env "$workspace"
|
|
1077
1078
|
local ws_path
|
|
1078
1079
|
ws_path="$(normalize_path "$(workspace_value "$workspace" WIKI_WORKSPACE_PATH)")"
|
|
1079
1080
|
ensure_workspace_dirs "$ws_path"
|