@dotdrelle/wiki-manager 0.14.9 → 0.14.12
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 +15 -1
- package/README.md +42 -2
- package/agents.docker-compose.yml +4 -5
- package/mcp.endpoints.example.json +2 -2
- package/package.json +2 -2
- package/src/agent/graph.js +8 -5
- package/src/agent/graph.test.js +4 -1
- package/src/cli/wiki-manager.js +73 -23
- package/src/commands/slash.js +33 -6
- package/src/commands/slash.test.js +17 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/cacert.js +11 -8
- package/src/core/cacert.test.js +35 -0
- package/src/core/compose.js +21 -3
- package/src/core/dockerCompose.test.js +3 -0
- package/src/core/dockerImages.js +22 -0
- package/src/core/env.js +27 -3
- package/src/core/env.test.js +72 -0
- package/src/core/mcp.js +1 -1
- package/src/core/startupCheck.js +340 -7
- package/src/core/startupCheck.test.js +190 -3
- package/src/core/wikiSetup.js +26 -2
- package/src/core/wikiWorkspace.test.js +9 -0
- package/src/runtime/lifecycle.js +1 -1
- package/src/shell/LeftPane.tsx +17 -2
- package/src/shell/RightPane.tsx +34 -17
- package/src/shell/SetupWizard.tsx +53 -10
- package/src/shell/StartupScreen.tsx +42 -9
- package/src/shell/externalLinks.js +67 -0
- package/src/shell/repl.js +149 -29
- package/src/shell/repl.test.js +187 -0
- package/src/shell/tui.tsx +60 -3
- package/wiki-workspace +20 -17
|
@@ -4,7 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import test from 'node:test';
|
|
7
|
-
import { runChecks } from './startupCheck.js';
|
|
7
|
+
import { checkInternetConnectivity, checkMcpConnections, runChecks, runPreflightChecks, withRuntimePreflight } from './startupCheck.js';
|
|
8
8
|
|
|
9
9
|
async function withWorkspace(wikircLines, fn) {
|
|
10
10
|
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-startup-check-'));
|
|
@@ -44,7 +44,13 @@ test('runChecks treats default LLM config without baseUrl as incomplete', async
|
|
|
44
44
|
' vector:',
|
|
45
45
|
' enabled: true',
|
|
46
46
|
], async () => {
|
|
47
|
-
const gaps = await runChecks(
|
|
47
|
+
const gaps = await runChecks({
|
|
48
|
+
dockerCheck: async () => ({ ok: true }),
|
|
49
|
+
internetCheck: async () => ({ ok: true }),
|
|
50
|
+
agentsCheck: async () => null,
|
|
51
|
+
workspaceContainersCheck: async () => ({ ok: true, detail: 'running' }),
|
|
52
|
+
mcpCheck: async () => ({ ok: true, detail: 'connected' }),
|
|
53
|
+
});
|
|
48
54
|
assert.equal(hasGap(gaps, 'llm'), true);
|
|
49
55
|
});
|
|
50
56
|
});
|
|
@@ -60,7 +66,188 @@ test('runChecks treats default LLM config with provider, baseUrl, apiKey and mod
|
|
|
60
66
|
' vector:',
|
|
61
67
|
' enabled: true',
|
|
62
68
|
], async () => {
|
|
63
|
-
const gaps = await runChecks(
|
|
69
|
+
const gaps = await runChecks({
|
|
70
|
+
dockerCheck: async () => ({ ok: true }),
|
|
71
|
+
internetCheck: async () => ({ ok: true }),
|
|
72
|
+
agentsCheck: async () => null,
|
|
73
|
+
workspaceContainersCheck: async () => ({ ok: true, detail: 'running' }),
|
|
74
|
+
mcpCheck: async () => ({ ok: true, detail: 'connected' }),
|
|
75
|
+
});
|
|
64
76
|
assert.equal(hasGap(gaps, 'llm'), false);
|
|
65
77
|
});
|
|
66
78
|
});
|
|
79
|
+
|
|
80
|
+
test('runChecks reports and probes every registered workspace', async () => {
|
|
81
|
+
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-multi-startup-'));
|
|
82
|
+
const registryRoot = join(root, 'registry');
|
|
83
|
+
for (const name of ['alpha', 'beta']) {
|
|
84
|
+
const registryPath = join(registryRoot, name);
|
|
85
|
+
const workspacePath = join(root, name);
|
|
86
|
+
mkdirSync(registryPath, { recursive: true });
|
|
87
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
88
|
+
writeFileSync(join(registryPath, '.env'), `WORKSPACE_NAME=${name}\nWIKI_WORKSPACE_PATH=${workspacePath}\n`, 'utf8');
|
|
89
|
+
writeFileSync(join(workspacePath, '.wikirc.yaml'), [
|
|
90
|
+
'llm:',
|
|
91
|
+
' provider: openai-compatible',
|
|
92
|
+
' baseUrl: http://localhost:8000/v1',
|
|
93
|
+
' apiKey: key',
|
|
94
|
+
' model: chat-model',
|
|
95
|
+
'',
|
|
96
|
+
].join('\n'), 'utf8');
|
|
97
|
+
}
|
|
98
|
+
const previousDir = process.env.WIKI_WORKSPACES_DIR;
|
|
99
|
+
process.env.WIKI_WORKSPACES_DIR = registryRoot;
|
|
100
|
+
try {
|
|
101
|
+
const containerCalls = [];
|
|
102
|
+
const mcpCalls = [];
|
|
103
|
+
const reports = [];
|
|
104
|
+
await runChecks({
|
|
105
|
+
dockerCheck: async () => ({ ok: true }),
|
|
106
|
+
internetCheck: async () => ({ ok: true }),
|
|
107
|
+
agentsCheck: async () => null,
|
|
108
|
+
workspaceContainersCheck: async (workspace) => {
|
|
109
|
+
containerCalls.push(workspace.name);
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
pending: true,
|
|
113
|
+
detail: 'to start',
|
|
114
|
+
context: { unavailable: ['serve', 'mcp-http', 'production-mcp'], command: `wiki-workspace up ${workspace.name}` },
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
mcpCheck: async (workspace) => {
|
|
118
|
+
mcpCalls.push(workspace.name);
|
|
119
|
+
return { ok: true, detail: 'connected', context: { endpoints: [{ name: 'wiki', status: 'connected' }] } };
|
|
120
|
+
},
|
|
121
|
+
onCheck: (check) => reports.push(check),
|
|
122
|
+
});
|
|
123
|
+
assert.deepEqual(containerCalls, ['alpha', 'beta']);
|
|
124
|
+
assert.deepEqual(mcpCalls, ['alpha', 'beta']);
|
|
125
|
+
const workspaceReport = reports.find((check) => check.kind === 'workspace');
|
|
126
|
+
assert.equal(workspaceReport.detail, '2 registered: alpha, beta');
|
|
127
|
+
assert.equal(
|
|
128
|
+
reports.find((check) => check.kind === 'containers').detail,
|
|
129
|
+
'2/2 workspaces to start: alpha, beta — services: serve, mcp-http, production-mcp',
|
|
130
|
+
);
|
|
131
|
+
assert.match(reports.find((check) => check.kind === 'mcp').detail, /2 workspace\(s\) checked/);
|
|
132
|
+
} finally {
|
|
133
|
+
if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
|
|
134
|
+
else process.env.WIKI_WORKSPACES_DIR = previousDir;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('runChecks executes Docker before Internet and reports both failures in order', async () => {
|
|
139
|
+
const calls = [];
|
|
140
|
+
const reports = [];
|
|
141
|
+
const gaps = await runChecks({
|
|
142
|
+
dockerCheck: async () => { calls.push('docker'); return { ok: false, context: { dockerUnavailable: true } }; },
|
|
143
|
+
internetCheck: async () => { calls.push('internet'); return { ok: false, context: { error: 'offline' } }; },
|
|
144
|
+
agentsCheck: async () => { calls.push('agents'); return null; },
|
|
145
|
+
onCheck: (check) => reports.push(check.kind),
|
|
146
|
+
});
|
|
147
|
+
assert.deepEqual(calls, ['docker', 'internet']);
|
|
148
|
+
assert.deepEqual(reports.slice(0, 3), ['docker', 'internet', 'agents']);
|
|
149
|
+
assert.deepEqual(gaps.slice(0, 2).map((gap) => gap.kind), ['agents', 'network']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('MCP preflight distinguishes authentication failure and skips remote endpoints while offline', async () => {
|
|
153
|
+
const workspace = { name: 'demo', workspacePath: '/tmp/demo', envFile: '/tmp/demo.env', env: {} };
|
|
154
|
+
const result = await checkMcpConnections(workspace, {
|
|
155
|
+
internetAvailable: false,
|
|
156
|
+
buildStatus: () => ({
|
|
157
|
+
local: { status: 'configured', url: 'http://127.0.0.1:3333/mcp' },
|
|
158
|
+
remote: { status: 'configured', url: 'https://mcp.example.test', external: true },
|
|
159
|
+
}),
|
|
160
|
+
discover: async (endpoints) => ({
|
|
161
|
+
local: { ...endpoints.local, status: 'configured', tools: [], toolError: '401 Unauthorized' },
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
assert.equal(result.ok, false);
|
|
165
|
+
assert.deepEqual(result.context.endpoints.map(({ name, status, reason }) => ({ name, status, reason })), [
|
|
166
|
+
{ name: 'local', status: 'failed', reason: 'authentication' },
|
|
167
|
+
{ name: 'remote', status: 'skipped', reason: 'internet unavailable' },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('MCP preflight reports invalid endpoint configuration without aborting startup', async () => {
|
|
172
|
+
const result = await checkMcpConnections({ name: 'demo', workspacePath: '/tmp/demo', env: {} }, {
|
|
173
|
+
buildStatus: () => { throw new Error('Unexpected token in mcp.endpoints.json'); },
|
|
174
|
+
});
|
|
175
|
+
assert.equal(result.ok, false);
|
|
176
|
+
assert.equal(result.detail, 'MCP configuration invalid');
|
|
177
|
+
assert.match(result.context.error, /mcp\.endpoints\.json/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('preflight status is setup_required without a valid workspace and runtime failure only degrades', async () => {
|
|
181
|
+
const previousDir = process.env.WIKI_WORKSPACES_DIR;
|
|
182
|
+
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-empty-startup-'));
|
|
183
|
+
process.env.WIKI_WORKSPACES_DIR = root;
|
|
184
|
+
try {
|
|
185
|
+
const preflight = await runPreflightChecks({
|
|
186
|
+
dockerCheck: async () => ({ ok: true }),
|
|
187
|
+
internetCheck: async () => ({ ok: true }),
|
|
188
|
+
agentsCheck: async () => null,
|
|
189
|
+
});
|
|
190
|
+
assert.equal(preflight.status, 'setup_required');
|
|
191
|
+
assert.equal(withRuntimePreflight(preflight, { error: 'offline' }).status, 'setup_required');
|
|
192
|
+
const ready = { gaps: [], status: 'ready', checks: [{ kind: 'docker', ok: true }] };
|
|
193
|
+
assert.equal(withRuntimePreflight(ready, { error: 'offline' }).status, 'degraded');
|
|
194
|
+
} finally {
|
|
195
|
+
if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
|
|
196
|
+
else process.env.WIKI_WORKSPACES_DIR = previousDir;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('runChecks presents Internet failure before stopped agents after Docker succeeds', async () => {
|
|
201
|
+
const gaps = await runChecks({
|
|
202
|
+
dockerCheck: async () => ({ ok: true }),
|
|
203
|
+
internetCheck: async () => ({ ok: false, context: { error: 'offline' } }),
|
|
204
|
+
agentsCheck: async () => ({ kind: 'agents', context: { downServices: ['documents'] } }),
|
|
205
|
+
workspaceContainersCheck: async () => ({ ok: false, detail: 'stopped' }),
|
|
206
|
+
mcpCheck: async () => ({ ok: false, detail: 'unavailable' }),
|
|
207
|
+
});
|
|
208
|
+
assert.deepEqual(gaps.slice(0, 2).map((gap) => gap.kind), ['network', 'agents']);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('runChecks still probes MCP endpoints when Docker is unavailable', async () => {
|
|
212
|
+
await withWorkspace([
|
|
213
|
+
'llm:',
|
|
214
|
+
' provider: openai-compatible',
|
|
215
|
+
' baseUrl: http://localhost:8000/v1',
|
|
216
|
+
' apiKey: key',
|
|
217
|
+
' model: chat-model',
|
|
218
|
+
], async () => {
|
|
219
|
+
const calls = [];
|
|
220
|
+
await runChecks({
|
|
221
|
+
dockerCheck: async () => ({ ok: false, context: { dockerUnavailable: true } }),
|
|
222
|
+
internetCheck: async () => ({ ok: true }),
|
|
223
|
+
agentsCheck: async () => { calls.push('agents'); return null; },
|
|
224
|
+
workspaceContainersCheck: async () => { calls.push('containers'); return { ok: true }; },
|
|
225
|
+
mcpCheck: async () => { calls.push('mcp'); return { ok: true, detail: 'remote connected' }; },
|
|
226
|
+
});
|
|
227
|
+
assert.deepEqual(calls, ['mcp']);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('checkInternetConnectivity uses a fresh Node process with proxy and CA environment', async () => {
|
|
232
|
+
const calls = [];
|
|
233
|
+
const previousProxy = process.env.HTTPS_PROXY;
|
|
234
|
+
const previousProxyFlag = process.env.NODE_USE_ENV_PROXY;
|
|
235
|
+
process.env.HTTPS_PROXY = 'http://proxy.test:8080';
|
|
236
|
+
process.env.NODE_USE_ENV_PROXY = '1';
|
|
237
|
+
try {
|
|
238
|
+
const result = await checkInternetConnectivity({
|
|
239
|
+
url: 'https://connectivity.test/ping',
|
|
240
|
+
exec: async (...args) => { calls.push(args); return { stdout: '' }; },
|
|
241
|
+
});
|
|
242
|
+
assert.equal(result.ok, true);
|
|
243
|
+
assert.equal(calls.length, 1);
|
|
244
|
+
assert.equal(calls[0][1].at(-1), 'https://connectivity.test/ping');
|
|
245
|
+
assert.equal(calls[0][2].env.HTTPS_PROXY, 'http://proxy.test:8080');
|
|
246
|
+
assert.equal(result.context.proxyEnabled, true);
|
|
247
|
+
} finally {
|
|
248
|
+
if (previousProxy === undefined) delete process.env.HTTPS_PROXY;
|
|
249
|
+
else process.env.HTTPS_PROXY = previousProxy;
|
|
250
|
+
if (previousProxyFlag === undefined) delete process.env.NODE_USE_ENV_PROXY;
|
|
251
|
+
else process.env.NODE_USE_ENV_PROXY = previousProxyFlag;
|
|
252
|
+
}
|
|
253
|
+
});
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
4
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import YAML from 'yaml';
|
|
7
|
+
import { checkMissingDockerImages } from './dockerImages.js';
|
|
5
8
|
import { patchWikircProfile } from './wikirc.js';
|
|
6
9
|
import { managerEnvFile, managerMcpEndpointsFile } from './env.js';
|
|
7
10
|
import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
8
11
|
|
|
9
12
|
const execFileAsync = promisify(execFile);
|
|
10
13
|
|
|
14
|
+
async function missingAgentImages() {
|
|
15
|
+
const composeFiles = [
|
|
16
|
+
join(managerRoot(), 'agents.docker-compose.yml'),
|
|
17
|
+
join(dirname(managerEnvFile()), 'agents.docker-compose.override.yml'),
|
|
18
|
+
].filter(existsSync);
|
|
19
|
+
const images = [...new Set(composeFiles.flatMap((filePath) => {
|
|
20
|
+
try {
|
|
21
|
+
const config = YAML.parse(readFileSync(filePath, 'utf8')) ?? {};
|
|
22
|
+
return Object.values(config.services ?? {}).map((service) => service?.image).filter(Boolean);
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}))];
|
|
27
|
+
return checkMissingDockerImages(images);
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
function wrapDockerError(err) {
|
|
12
31
|
if (err?.code === 'ENOENT') {
|
|
13
32
|
return new Error('wiki-workspace script not found. Reinstall wiki-manager.');
|
|
@@ -35,6 +54,8 @@ function wrapDockerError(err) {
|
|
|
35
54
|
|
|
36
55
|
export async function startAgents(options = {}) {
|
|
37
56
|
try {
|
|
57
|
+
const absentImages = await missingAgentImages();
|
|
58
|
+
if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
|
|
38
59
|
const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
|
|
39
60
|
cwd: managerRoot(),
|
|
40
61
|
env: {
|
|
@@ -51,7 +72,10 @@ export async function startAgents(options = {}) {
|
|
|
51
72
|
timeout: options.timeout ?? 180_000,
|
|
52
73
|
maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
|
|
53
74
|
});
|
|
54
|
-
return
|
|
75
|
+
return {
|
|
76
|
+
output: [stdout, stderr].filter(Boolean).join('\n').trim(),
|
|
77
|
+
missingImages: absentImages,
|
|
78
|
+
};
|
|
55
79
|
} catch (err) {
|
|
56
80
|
throw wrapDockerError(err);
|
|
57
81
|
}
|
|
@@ -22,3 +22,12 @@ test('wiki-workspace checks runtime pid command before killing', async () => {
|
|
|
22
22
|
assert.match(script, /if ! runtime_pid_matches; then\n printf 'refusing to stop pid/);
|
|
23
23
|
assert.match(script, /kill "\$\(cat "\$pid_file"\)"/);
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
test('wiki-workspace regenerates CA compose overrides instead of retaining removed services', async () => {
|
|
27
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
28
|
+
|
|
29
|
+
assert.doesNotMatch(script, /if \[\[ ! -f "\$override_path" \]\]; then/);
|
|
30
|
+
assert.match(script, /local tmp_override="\$override_path\.tmp\.\$\$"/);
|
|
31
|
+
assert.match(script, /mv "\$tmp_override" "\$override_path"/);
|
|
32
|
+
assert.match(script, /Changes are overwritten on the next compose command/);
|
|
33
|
+
});
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -83,7 +83,7 @@ export async function ensureRuntime({
|
|
|
83
83
|
if (!forceRestart && !stale && actualCacertPath === expectedCacertPath) {
|
|
84
84
|
return { url, started: false, health: existing, token: auth.token, tokenPath: auth.tokenPath };
|
|
85
85
|
}
|
|
86
|
-
if (stale) console.
|
|
86
|
+
if (stale) console.log('\x1b[32mruntime: source changed since start — restarting for fresh code.\x1b[0m');
|
|
87
87
|
await postRuntimeShutdown({ url, token: auth.token });
|
|
88
88
|
await waitForRuntimeShutdown(url, auth.token, 2500);
|
|
89
89
|
}
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
3
3
|
import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
|
|
4
|
+
import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
|
|
4
5
|
|
|
5
6
|
const LEGACY_DONNA_ROLE = 'do' + 't';
|
|
6
7
|
|
|
@@ -47,7 +48,7 @@ function wrapLine(line: string, width: number) {
|
|
|
47
48
|
return out;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
type Segment = { text: string; color: string; width?: number; bg?: string };
|
|
51
|
+
type Segment = { text: string; color: string; width?: number; bg?: string; url?: string };
|
|
51
52
|
type RenderedLine = {
|
|
52
53
|
segments: Segment[];
|
|
53
54
|
status?: boolean;
|
|
@@ -300,6 +301,16 @@ function renderMarkdownLines(lines: Array<{ text: string; isCode: boolean }>, ro
|
|
|
300
301
|
const rendered = renderPlainMarkdown(text);
|
|
301
302
|
const isCmdDesc = splitCmdDesc(rendered) !== null;
|
|
302
303
|
const fallback = isCmdDesc ? '#FFFFFF' : colorForRenderedLine(rendered, role);
|
|
304
|
+
if (httpLinkParts(rendered).some((part: { url?: string }) => part.url)) {
|
|
305
|
+
output.push(...wrapHttpLinks(rendered, columns).map((parts: Array<{ text: string; url?: string }>) => ({
|
|
306
|
+
segments: parts.map((part) => ({
|
|
307
|
+
text: part.text || ' ',
|
|
308
|
+
color: part.url ? '#5DADE2' : fallback,
|
|
309
|
+
url: part.url,
|
|
310
|
+
})),
|
|
311
|
+
})));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
303
314
|
output.push(...wrapLine(rendered, columns).map((piece, idx) => ({
|
|
304
315
|
segments: idx === 0
|
|
305
316
|
? segmentsForLine(piece, role, columns)
|
|
@@ -489,7 +500,11 @@ export function ConversationView(props: {
|
|
|
489
500
|
) : (
|
|
490
501
|
<box height={1} flexDirection="row" overflow="hidden">
|
|
491
502
|
<For each={line.segments}>
|
|
492
|
-
{(seg: Segment) =>
|
|
503
|
+
{(seg: Segment) => (
|
|
504
|
+
<text width={seg.width} fg={seg.color} bg={seg.bg}>
|
|
505
|
+
{seg.url ? <a href={seg.url}>{seg.text}</a> : seg.text}
|
|
506
|
+
</text>
|
|
507
|
+
)}
|
|
493
508
|
</For>
|
|
494
509
|
</box>
|
|
495
510
|
)
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -20,6 +20,7 @@ type LogLineParts = { time: string | null; message: string };
|
|
|
20
20
|
// wrapped status/error), so fewer, readable entries beat more, truncated ones.
|
|
21
21
|
const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
|
|
22
22
|
const LOG_SLOTS = Array.from({ length: 24 }, (_, index) => index);
|
|
23
|
+
const PLAN_VIEWPORT_ROWS = 12;
|
|
23
24
|
|
|
24
25
|
function wrapLine(value: string, width: number) {
|
|
25
26
|
const max = Math.max(8, width);
|
|
@@ -131,29 +132,45 @@ function queueSummary(item: QueueItem) {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string }) {
|
|
134
|
-
|
|
135
|
+
// Keep one column for the native vertical scrollbar when the plan is long.
|
|
136
|
+
const lineWidth = () => Math.max(8, props.width - 3);
|
|
135
137
|
const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
|
|
136
138
|
const icon = (status: string) =>
|
|
137
139
|
status === 'done' ? '[✓]' : status === 'failed' ? '[✗]' : status === 'running' ? '[…]' : '[ ]';
|
|
138
|
-
const
|
|
140
|
+
const visualRows = createMemo(() => props.plan.reduce((total, step) =>
|
|
141
|
+
total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, lineWidth()).slice(0, 2).length, 0));
|
|
142
|
+
const title = () => {
|
|
143
|
+
const label = props.jobName ? `Plan : ${props.jobName}` : 'Plan';
|
|
144
|
+
return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
|
|
145
|
+
};
|
|
146
|
+
const viewportRows = () => Math.min(PLAN_VIEWPORT_ROWS, Math.max(1, visualRows()));
|
|
139
147
|
return (
|
|
140
148
|
<box flexShrink={0} flexDirection="column" padding={1}>
|
|
141
149
|
<text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
|
|
142
|
-
<
|
|
143
|
-
{(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
<scrollbox
|
|
151
|
+
height={viewportRows()}
|
|
152
|
+
scrollY={true}
|
|
153
|
+
scrollX={false}
|
|
154
|
+
stickyStart="top"
|
|
155
|
+
viewportCulling={true}
|
|
156
|
+
verticalScrollbarOptions={{ visible: visualRows() > PLAN_VIEWPORT_ROWS }}
|
|
157
|
+
>
|
|
158
|
+
<Index each={props.plan}>
|
|
159
|
+
{(step) => {
|
|
160
|
+
// Wrap step descriptions over up to 2 lines instead of truncating —
|
|
161
|
+
// "Ingest des 39 documents raw/untrac…" hid the actual target.
|
|
162
|
+
const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
|
|
163
|
+
return (
|
|
164
|
+
<box flexShrink={0} flexDirection="column">
|
|
165
|
+
<text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
|
|
166
|
+
<Show when={lines()[1]}>
|
|
167
|
+
<text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
|
|
168
|
+
</Show>
|
|
169
|
+
</box>
|
|
170
|
+
);
|
|
171
|
+
}}
|
|
172
|
+
</Index>
|
|
173
|
+
</scrollbox>
|
|
157
174
|
</box>
|
|
158
175
|
);
|
|
159
176
|
}
|
|
@@ -4,6 +4,7 @@ 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 { fallbackModels, normalizeProvider } from '../core/modelFetch.js';
|
|
7
|
+
import { checkInternetConnectivity } from '../core/startupCheck.js';
|
|
7
8
|
import {
|
|
8
9
|
createNewWorkspace,
|
|
9
10
|
deleteWorkspaceAndFiles,
|
|
@@ -17,13 +18,13 @@ import {
|
|
|
17
18
|
import { listWorkspaces, workspacesDir } from '../core/workspaces.js';
|
|
18
19
|
import { loadWikircProfile } from '../core/wikirc.js';
|
|
19
20
|
|
|
20
|
-
type Gap = { kind: 'agents' | 'workspace' | 'llm' | 'vector'; context?: Record<string, any> };
|
|
21
|
+
type Gap = { kind: 'agents' | 'network' | 'workspace' | 'llm' | 'vector'; context?: Record<string, any> };
|
|
21
22
|
type Mode = 'startup' | 'setup';
|
|
22
23
|
type Step =
|
|
23
24
|
| { kind: 'menu'; title: string; items: Array<{ label: string; value: string; muted?: boolean }> }
|
|
24
25
|
| { kind: 'confirm'; title: string; message: string; yesLabel: string; noLabel: string }
|
|
25
26
|
| { kind: 'select'; title: string; label: string; options: string[]; note?: string }
|
|
26
|
-
| { kind: 'text'; title: string; label: string; placeholder?: string; prefill?: string; secret?: boolean }
|
|
27
|
+
| { kind: 'text'; title: string; label: string; note?: string; placeholder?: string; prefill?: string; secret?: boolean }
|
|
27
28
|
| { kind: 'done' };
|
|
28
29
|
type LogEntry = { icon: string; label: string; detail?: string };
|
|
29
30
|
|
|
@@ -195,6 +196,20 @@ export function SetupWizard(props: {
|
|
|
195
196
|
noLabel: 'Skip',
|
|
196
197
|
};
|
|
197
198
|
}
|
|
199
|
+
if (currentRoute === 'network') {
|
|
200
|
+
const context = currentGap()?.context ?? {};
|
|
201
|
+
const transport = context.proxyUrl
|
|
202
|
+
? `Proxy: ${context.proxyEnabled ? context.proxyUrl : `${context.proxyUrl} (NODE_USE_ENV_PROXY is not enabled)`}`
|
|
203
|
+
: 'Proxy: direct connection';
|
|
204
|
+
const certificate = context.cacertPath ? `CA: ${context.cacertPath}` : 'CA: system trust store';
|
|
205
|
+
return {
|
|
206
|
+
kind: 'confirm',
|
|
207
|
+
title: 'Internet connectivity',
|
|
208
|
+
message: `Could not reach ${context.url ?? 'the connectivity endpoint'}.\n${transport}\n${certificate}\n${context.error ?? ''}`.trim(),
|
|
209
|
+
yesLabel: 'Retry',
|
|
210
|
+
noLabel: 'Skip',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
198
213
|
if (currentRoute === 'workspace-confirm') {
|
|
199
214
|
return { kind: 'confirm', title: 'Workspace', message: 'No workspace configured.', yesLabel: 'Create', noLabel: 'Skip' };
|
|
200
215
|
}
|
|
@@ -219,21 +234,32 @@ export function SetupWizard(props: {
|
|
|
219
234
|
};
|
|
220
235
|
}
|
|
221
236
|
if (currentRoute === 'llm-baseurl') {
|
|
222
|
-
const
|
|
223
|
-
return {
|
|
237
|
+
const example = defaultBaseUrl(llm().provider);
|
|
238
|
+
return {
|
|
239
|
+
kind: 'text',
|
|
240
|
+
title: 'LLM configuration',
|
|
241
|
+
label: `Base URL${example ? ` (example: ${example})` : ''}`,
|
|
242
|
+
prefill: llm().baseUrl || '',
|
|
243
|
+
};
|
|
224
244
|
}
|
|
225
245
|
if (currentRoute === 'llm-apikey') {
|
|
226
246
|
return { kind: 'text', title: 'LLM configuration', label: 'API key (required)', secret: true };
|
|
227
247
|
}
|
|
228
248
|
if (currentRoute === 'llm-model') {
|
|
229
|
-
const
|
|
230
|
-
return {
|
|
249
|
+
const example = fallbackModels(llm().provider)[0] || 'provider-agentic-model';
|
|
250
|
+
return {
|
|
251
|
+
kind: 'text',
|
|
252
|
+
title: 'LLM configuration',
|
|
253
|
+
label: `Model (example: ${example})`,
|
|
254
|
+
note: 'Required: an agentic model with tool/function calling support.',
|
|
255
|
+
prefill: llm().model || '',
|
|
256
|
+
};
|
|
231
257
|
}
|
|
232
258
|
if (currentRoute === 'vector-confirm') {
|
|
233
259
|
return { kind: 'confirm', title: 'Vector search', message: 'Configure vector search?', yesLabel: 'Enable', noLabel: 'Skip' };
|
|
234
260
|
}
|
|
235
261
|
if (currentRoute === 'vector-baseurl') {
|
|
236
|
-
const baseUrl = vector().baseUrl || llm().baseUrl
|
|
262
|
+
const baseUrl = vector().baseUrl || llm().baseUrl;
|
|
237
263
|
return { kind: 'text', title: 'Vector search', label: 'Embeddings/rerank base URL', prefill: baseUrl, placeholder: baseUrl };
|
|
238
264
|
}
|
|
239
265
|
if (currentRoute === 'vector-apikey') {
|
|
@@ -326,6 +352,7 @@ export function SetupWizard(props: {
|
|
|
326
352
|
function startupRoute(gap?: Gap) {
|
|
327
353
|
if (!gap) return 'done';
|
|
328
354
|
if (gap.kind === 'agents') return 'agents';
|
|
355
|
+
if (gap.kind === 'network') return 'network';
|
|
329
356
|
if (gap.kind === 'workspace') return 'workspace-confirm';
|
|
330
357
|
if (gap.kind === 'llm') return 'llm-provider';
|
|
331
358
|
if (gap.kind === 'vector') return 'vector-confirm';
|
|
@@ -462,7 +489,10 @@ export function SetupWizard(props: {
|
|
|
462
489
|
if (currentRoute === 'llm-provider') {
|
|
463
490
|
const provider = normalizeProvider(value);
|
|
464
491
|
setLlm((old: any) => {
|
|
465
|
-
const
|
|
492
|
+
const asksForBaseUrl = provider === 'ollama' || provider === 'openai-compatible';
|
|
493
|
+
const baseUrl = (old.provider === provider && old.baseUrl)
|
|
494
|
+
? old.baseUrl
|
|
495
|
+
: asksForBaseUrl ? '' : defaultBaseUrl(provider);
|
|
466
496
|
return { ...old, provider, baseUrl, ...(provider === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
|
|
467
497
|
});
|
|
468
498
|
if (provider === 'ollama' || provider === 'openai-compatible') return navigate('llm-baseurl');
|
|
@@ -513,6 +543,14 @@ export function SetupWizard(props: {
|
|
|
513
543
|
});
|
|
514
544
|
return;
|
|
515
545
|
}
|
|
546
|
+
if (currentRoute === 'network') {
|
|
547
|
+
await runAction(async () => {
|
|
548
|
+
const result = await checkInternetConnectivity();
|
|
549
|
+
if (!result.ok) throw new Error(result.context?.error ?? 'Internet connectivity check failed.');
|
|
550
|
+
nextStartup('Internet connectivity verified');
|
|
551
|
+
});
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
516
554
|
if (currentRoute === 'workspace-confirm') return navigate('workspace-name');
|
|
517
555
|
if (currentRoute === 'vector-rerank') return navigate('vector-rerank-model');
|
|
518
556
|
if (currentRoute === 'vector-confirm') {
|
|
@@ -560,7 +598,8 @@ export function SetupWizard(props: {
|
|
|
560
598
|
return;
|
|
561
599
|
}
|
|
562
600
|
if (currentRoute === 'llm-baseurl') {
|
|
563
|
-
|
|
601
|
+
if (!value) return setError('Base URL is required.');
|
|
602
|
+
setLlm((old: any) => ({ ...old, baseUrl: value }));
|
|
564
603
|
if (llm().provider === 'ollama') return navigate('llm-model');
|
|
565
604
|
return navigate('llm-apikey');
|
|
566
605
|
}
|
|
@@ -570,7 +609,8 @@ export function SetupWizard(props: {
|
|
|
570
609
|
return navigate('llm-model');
|
|
571
610
|
}
|
|
572
611
|
if (currentRoute === 'vector-baseurl') {
|
|
573
|
-
const baseUrl = value || vector().baseUrl || llm().baseUrl
|
|
612
|
+
const baseUrl = value || vector().baseUrl || llm().baseUrl;
|
|
613
|
+
if (!baseUrl) return setError('Embeddings/rerank base URL is required.');
|
|
574
614
|
setVector((old: any) => ({ ...old, provider: llm().provider, baseUrl }));
|
|
575
615
|
return navigate('vector-apikey');
|
|
576
616
|
}
|
|
@@ -750,6 +790,9 @@ export function SetupWizard(props: {
|
|
|
750
790
|
<Show when={(step() as any).message || (step() as any).label}>
|
|
751
791
|
<text height={1} fg="#D6DEE8">{(step() as any).message ?? (step() as any).label}</text>
|
|
752
792
|
</Show>
|
|
793
|
+
<Show when={(step() as any).note}>
|
|
794
|
+
<text height={1} fg="#9CA3AF">{(step() as any).note}</text>
|
|
795
|
+
</Show>
|
|
753
796
|
<Show when={step().kind === 'text'}>
|
|
754
797
|
<box
|
|
755
798
|
height={5}
|