living-ai-documentation 2.0.0 → 2.1.0
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/dist/src/frontend/config.js +3 -2
- package/dist/src/frontend/i18n/en.json +1 -1
- package/dist/src/frontend/i18n/fr.json +1 -1
- package/dist/src/frontend/index.html +38 -37
- package/dist/src/frontend/workspace/app.js +331 -66
- package/dist/src/frontend/workspace/app.js.map +1 -1
- package/dist/src/frontend/workspace/app.ts +424 -82
- package/dist/src/frontend/workspace/index.html +142 -71
- package/dist/src/frontend/workspace/persistence.js +61 -0
- package/dist/src/frontend/workspace/persistence.js.map +1 -1
- package/dist/src/frontend/workspace/persistence.ts +110 -0
- package/dist/src/frontend/workspace/styles.css +251 -32
- package/dist/src/routes/workspace.d.ts.map +1 -1
- package/dist/src/routes/workspace.js +377 -6
- package/dist/src/routes/workspace.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadWorkspaceState, saveWorkspaceState, } from "./persistence.js";
|
|
1
|
+
import { loadWorkspaceState, saveWorkspaceState, testLlmConnection, listLlmModels, runAgentPrompt, } from "./persistence.js";
|
|
2
2
|
const LEAF_WIDTH = 164;
|
|
3
3
|
const LEAF_HEIGHT = 64;
|
|
4
4
|
const ROOT_RADIUS = 132;
|
|
@@ -6,10 +6,10 @@ const PROVIDER_RADIUS = 78;
|
|
|
6
6
|
const MIN_POLYGON_SIDES = 6;
|
|
7
7
|
const GRID_MINOR = 24;
|
|
8
8
|
const GRID_MAJOR = 96;
|
|
9
|
-
const PANEL_MIN_WIDTH =
|
|
10
|
-
const PANEL_MAX_WIDTH =
|
|
11
|
-
const PANEL_VIEWPORT_RATIO = 1 /
|
|
12
|
-
const PANEL_HEIGHT =
|
|
9
|
+
const PANEL_MIN_WIDTH = 540;
|
|
10
|
+
const PANEL_MAX_WIDTH = 780;
|
|
11
|
+
const PANEL_VIEWPORT_RATIO = 1 / 2;
|
|
12
|
+
const PANEL_HEIGHT = 9999;
|
|
13
13
|
const PANEL_GAP = 44;
|
|
14
14
|
const ROOT_CHILD_DISTANCE = 255;
|
|
15
15
|
const PROVIDER_CHILD_DISTANCE = 178;
|
|
@@ -43,13 +43,22 @@ const ROOT_CLUSTER_RELAXATION_STEP = 48;
|
|
|
43
43
|
const ROOT_CLUSTER_RELAXATION_ATTEMPTS = 7;
|
|
44
44
|
const ROOT_CLUSTER_MAX_DISTANCE = ROOT_CHILD_DISTANCE + ROOT_CHILD_MAX_EXTRA_DISTANCE + 260;
|
|
45
45
|
const SAVE_DEBOUNCE_MS = 450;
|
|
46
|
+
const SAVE_TOAST_MS = 2600;
|
|
46
47
|
const canvas = document.getElementById("workspaceCanvas");
|
|
47
48
|
const ctx = canvas.getContext("2d");
|
|
48
49
|
const apiStatus = document.getElementById("apiStatus");
|
|
49
50
|
const addButton = document.getElementById("addButton");
|
|
50
51
|
const fitButton = document.getElementById("fitButton");
|
|
51
|
-
const
|
|
52
|
+
const testNodeButton = document.getElementById("testNodeButton");
|
|
53
|
+
const loadModelsButton = document.getElementById("loadModelsButton");
|
|
54
|
+
const runAgentButton = document.getElementById("runAgentButton");
|
|
55
|
+
const clearAgentButton = document.getElementById("clearAgentButton");
|
|
56
|
+
const agentResponses = document.getElementById("agentResponses");
|
|
52
57
|
const deleteNodeButton = document.getElementById("deleteNodeButton");
|
|
58
|
+
const renameConfirmOverlay = document.getElementById("renameConfirmOverlay");
|
|
59
|
+
const renameConfirmMessage = document.getElementById("renameConfirmMessage");
|
|
60
|
+
const cancelRenameButton = document.getElementById("cancelRenameButton");
|
|
61
|
+
const confirmRenameButton = document.getElementById("confirmRenameButton");
|
|
53
62
|
const deleteConfirmOverlay = document.getElementById("deleteConfirmOverlay");
|
|
54
63
|
const deleteConfirmMessage = document.getElementById("deleteConfirmMessage");
|
|
55
64
|
const cancelDeleteButton = document.getElementById("cancelDeleteButton");
|
|
@@ -57,27 +66,37 @@ const confirmDeleteButton = document.getElementById("confirmDeleteButton");
|
|
|
57
66
|
const fallbackPanelHost = document.getElementById("fallbackPanelHost");
|
|
58
67
|
const configSurface = document.getElementById("configSurface");
|
|
59
68
|
const form = configSurface;
|
|
69
|
+
const workspaceToast = document.getElementById("workspaceToast");
|
|
70
|
+
const workspaceToastIcon = document.getElementById("workspaceToastIcon");
|
|
71
|
+
const workspaceToastMessage = document.getElementById("workspaceToastMessage");
|
|
72
|
+
let toastTimerId = null;
|
|
73
|
+
let savedAgentLabel = null;
|
|
74
|
+
let savedAgentFolder = null;
|
|
60
75
|
const fields = {
|
|
61
76
|
name: document.getElementById("nodeName"),
|
|
62
77
|
kind: document.getElementById("nodeKind"),
|
|
63
|
-
environment: document.getElementById("nodeEnvironment"),
|
|
64
78
|
endpoint: document.getElementById("nodeEndpoint"),
|
|
65
79
|
token: document.getElementById("nodeToken"),
|
|
80
|
+
model: document.getElementById("nodeModel"),
|
|
81
|
+
workspaceField: document.getElementById("agentWorkspaceField"),
|
|
82
|
+
workspaceFolder: document.getElementById("nodeWorkspaceFolder"),
|
|
66
83
|
timeout: document.getElementById("nodeTimeout"),
|
|
67
|
-
retryPolicy: document.getElementById("nodeRetryPolicy"),
|
|
68
84
|
description: document.getElementById("nodeDescription"),
|
|
69
85
|
typeBadge: document.getElementById("nodeTypeBadge"),
|
|
70
|
-
|
|
86
|
+
llmFields: document.getElementById("llmFields"),
|
|
71
87
|
mcpInventory: document.getElementById("mcpInventory"),
|
|
88
|
+
agentSection: document.getElementById("agentSection"),
|
|
89
|
+
systemPrompt: document.getElementById("nodeSystemPrompt"),
|
|
90
|
+
userInput: document.getElementById("nodeUserInput"),
|
|
72
91
|
};
|
|
73
92
|
const initialEntities = [
|
|
74
93
|
createEntity("root", "Living AI Documentation", "system", null),
|
|
75
|
-
createEntity("devstral", "DevStral2 LLM API", "llm", "root"),
|
|
76
|
-
createEntity("qwen", "Qwen2 LLM API", "llm", "root"),
|
|
77
94
|
createEntity("mcp", "MCP", "mcp", "root"),
|
|
78
|
-
createEntity("
|
|
79
|
-
createEntity("
|
|
80
|
-
createEntity("
|
|
95
|
+
//createEntity("devstral", "DevStral2 LLM API", "llm", "root"),
|
|
96
|
+
//createEntity("qwen", "Qwen2 LLM API", "llm", "root"),
|
|
97
|
+
//createEntity("review-agent", "Code Review Agent", "agent", "devstral"),
|
|
98
|
+
//createEntity("sonar-agent", "Sonarqube Metrics Agent", "agent", "devstral"),
|
|
99
|
+
//createEntity("doc-agent", "Documentation Agent", "agent", "qwen"),
|
|
81
100
|
];
|
|
82
101
|
const state = {
|
|
83
102
|
entities: cloneEntities(initialEntities),
|
|
@@ -135,16 +154,6 @@ fitButton.addEventListener("click", () => {
|
|
|
135
154
|
scheduleWorkspaceSave();
|
|
136
155
|
scheduleRender();
|
|
137
156
|
});
|
|
138
|
-
resetButton.addEventListener("click", () => {
|
|
139
|
-
state.entities = cloneEntities(initialEntities);
|
|
140
|
-
state.selectedId = null;
|
|
141
|
-
state.lastSaveAt = null;
|
|
142
|
-
layoutGraph();
|
|
143
|
-
resetView(true, true);
|
|
144
|
-
syncPanelFromSelection();
|
|
145
|
-
scheduleWorkspaceSave();
|
|
146
|
-
scheduleRender();
|
|
147
|
-
});
|
|
148
157
|
canvas.addEventListener("pointerdown", (event) => {
|
|
149
158
|
if (event.target !== canvas) {
|
|
150
159
|
return;
|
|
@@ -155,7 +164,8 @@ canvas.addEventListener("pointerdown", (event) => {
|
|
|
155
164
|
.reverse()
|
|
156
165
|
.find((entity) => isEntityHit(entity, worldPoint));
|
|
157
166
|
if (hitEntity) {
|
|
158
|
-
|
|
167
|
+
if (hitEntity.id !== "root")
|
|
168
|
+
selectEntity(hitEntity.id);
|
|
159
169
|
return;
|
|
160
170
|
}
|
|
161
171
|
state.isPanning = true;
|
|
@@ -212,16 +222,18 @@ form.addEventListener("submit", (event) => {
|
|
|
212
222
|
event.preventDefault();
|
|
213
223
|
syncSelectedFromForm();
|
|
214
224
|
state.lastSaveAt = new Date();
|
|
215
|
-
fields.healthBadge.textContent = "Applied";
|
|
216
225
|
layoutGraph();
|
|
217
|
-
void saveWorkspaceNow();
|
|
226
|
+
void saveWorkspaceNow(true);
|
|
218
227
|
scheduleRender();
|
|
219
228
|
});
|
|
220
|
-
form.addEventListener("input", () => {
|
|
229
|
+
form.addEventListener("input", (event) => {
|
|
221
230
|
syncSelectedFromForm();
|
|
222
|
-
fields.healthBadge.textContent = "Draft";
|
|
223
231
|
layoutGraph();
|
|
224
|
-
|
|
232
|
+
// Don't auto-save while editing the name of an agent — wait for blur + popup
|
|
233
|
+
const isAgentNameEdit = event.target === fields.name && selectedEntity()?.kind === "agent";
|
|
234
|
+
if (!isAgentNameEdit) {
|
|
235
|
+
scheduleWorkspaceSave();
|
|
236
|
+
}
|
|
225
237
|
scheduleRender();
|
|
226
238
|
});
|
|
227
239
|
deleteNodeButton.addEventListener("click", (event) => {
|
|
@@ -246,9 +258,64 @@ confirmDeleteButton.addEventListener("click", () => {
|
|
|
246
258
|
}
|
|
247
259
|
closeDeleteConfirmation();
|
|
248
260
|
});
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
261
|
+
testNodeButton.addEventListener("click", () => {
|
|
262
|
+
void testSelectedLlmConnection();
|
|
263
|
+
});
|
|
264
|
+
loadModelsButton.addEventListener("click", () => {
|
|
265
|
+
void loadModelsForSelect();
|
|
266
|
+
});
|
|
267
|
+
fields.name.addEventListener("blur", () => {
|
|
268
|
+
const selected = selectedEntity();
|
|
269
|
+
if (!selected || selected.kind !== "agent")
|
|
270
|
+
return;
|
|
271
|
+
const newName = fields.name.value.trim();
|
|
272
|
+
if (!newName || !savedAgentLabel || !savedAgentFolder || newName === savedAgentLabel)
|
|
273
|
+
return;
|
|
274
|
+
const newFolder = workspaceFolderForProvider(newName);
|
|
275
|
+
if (newFolder === savedAgentFolder)
|
|
276
|
+
return; // slug identique, pas de renommage nécessaire
|
|
277
|
+
renameConfirmMessage.textContent =
|
|
278
|
+
`Renommer le dossier "${savedAgentFolder}" en "${newFolder}" ?`;
|
|
279
|
+
renameConfirmOverlay.hidden = false;
|
|
280
|
+
cancelRenameButton.focus();
|
|
281
|
+
});
|
|
282
|
+
cancelRenameButton.addEventListener("click", () => {
|
|
283
|
+
renameConfirmOverlay.hidden = true;
|
|
284
|
+
const selected = selectedEntity();
|
|
285
|
+
if (selected && savedAgentLabel) {
|
|
286
|
+
selected.label = savedAgentLabel;
|
|
287
|
+
fields.name.value = savedAgentLabel;
|
|
288
|
+
layoutGraph();
|
|
289
|
+
scheduleRender();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
confirmRenameButton.addEventListener("click", () => {
|
|
293
|
+
renameConfirmOverlay.hidden = true;
|
|
294
|
+
const selected = selectedEntity();
|
|
295
|
+
if (!selected || !savedAgentFolder)
|
|
296
|
+
return;
|
|
297
|
+
// Remettre l'ancien chemin pour que le backend sache quoi renommer
|
|
298
|
+
selected.config.workspaceFolder = savedAgentFolder;
|
|
299
|
+
fields.workspaceFolder.value = savedAgentFolder;
|
|
300
|
+
const newName = fields.name.value.trim();
|
|
301
|
+
if (newName)
|
|
302
|
+
savedAgentLabel = newName;
|
|
303
|
+
savedAgentFolder = workspaceFolderForProvider(newName);
|
|
304
|
+
scheduleWorkspaceSave();
|
|
305
|
+
});
|
|
306
|
+
fields.model.addEventListener("change", () => {
|
|
307
|
+
testNodeButton.disabled = !fields.model.value;
|
|
308
|
+
const selected = selectedEntity();
|
|
309
|
+
if (selected) {
|
|
310
|
+
selected.config.model = fields.model.value;
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
runAgentButton.addEventListener("click", () => {
|
|
314
|
+
void runAgent();
|
|
315
|
+
});
|
|
316
|
+
clearAgentButton.addEventListener("click", () => {
|
|
317
|
+
agentResponses.innerHTML = "";
|
|
318
|
+
agentResponses.hidden = true;
|
|
252
319
|
});
|
|
253
320
|
void initializeWorkspace();
|
|
254
321
|
function isolatePanelEvents() {
|
|
@@ -260,6 +327,7 @@ function isolatePanelEvents() {
|
|
|
260
327
|
function isolateConfirmEvents() {
|
|
261
328
|
for (const type of PANEL_EVENT_TYPES) {
|
|
262
329
|
deleteConfirmOverlay.addEventListener(type, stopPanelEventPropagation);
|
|
330
|
+
renameConfirmOverlay.addEventListener(type, stopPanelEventPropagation);
|
|
263
331
|
}
|
|
264
332
|
}
|
|
265
333
|
function stopPanelEventPropagation(event) {
|
|
@@ -272,7 +340,9 @@ async function initializeWorkspace() {
|
|
|
272
340
|
state.cameraX = finiteNumber(persisted.camera?.x, state.cameraX);
|
|
273
341
|
state.cameraY = finiteNumber(persisted.camera?.y, state.cameraY);
|
|
274
342
|
state.zoom = clamp(finiteNumber(persisted.camera?.zoom, state.zoom), ZOOM_MIN, ZOOM_MAX);
|
|
275
|
-
state.lastSaveAt = persisted.updatedAt
|
|
343
|
+
state.lastSaveAt = persisted.updatedAt
|
|
344
|
+
? new Date(persisted.updatedAt)
|
|
345
|
+
: null;
|
|
276
346
|
}
|
|
277
347
|
state.isHydrated = true;
|
|
278
348
|
layoutGraph();
|
|
@@ -289,13 +359,19 @@ function hydrateEntities(entities) {
|
|
|
289
359
|
const hasRoot = hydrated.some((entity) => entity.id === "root");
|
|
290
360
|
const hasMcp = hydrated.some((entity) => entity.id === "mcp");
|
|
291
361
|
const withRequiredNodes = [
|
|
292
|
-
...(hasRoot
|
|
362
|
+
...(hasRoot
|
|
363
|
+
? []
|
|
364
|
+
: [createEntity("root", "Living AI Documentation", "system", null)]),
|
|
293
365
|
...hydrated,
|
|
294
366
|
...(hasMcp ? [] : [createEntity("mcp", "MCP", "mcp", "root")]),
|
|
295
367
|
];
|
|
296
368
|
const ids = new Set(withRequiredNodes.map((entity) => entity.id));
|
|
297
369
|
return withRequiredNodes.map((entity) => {
|
|
298
|
-
const normalizedKind = entity.id === "root"
|
|
370
|
+
const normalizedKind = entity.id === "root"
|
|
371
|
+
? "system"
|
|
372
|
+
: entity.id === "mcp"
|
|
373
|
+
? "mcp"
|
|
374
|
+
: entity.kind;
|
|
299
375
|
const normalizedParentId = entity.id === "root"
|
|
300
376
|
? null
|
|
301
377
|
: entity.id === "mcp"
|
|
@@ -335,12 +411,14 @@ function hydrateEntity(input) {
|
|
|
335
411
|
y: finiteNumber(input.y, 0),
|
|
336
412
|
angle: finiteNumber(input.angle, 0),
|
|
337
413
|
config: {
|
|
338
|
-
environment: stringValue(config.environment) || defaultEnvironment(kind),
|
|
339
414
|
endpoint: stringValue(config.endpoint) || defaultEndpoint(id, kind),
|
|
340
415
|
token: stringValue(config.token),
|
|
341
|
-
|
|
342
|
-
|
|
416
|
+
model: stringValue(config.model),
|
|
417
|
+
timeout: finiteNumber(config.timeout, 180),
|
|
343
418
|
description: stringValue(config.description) || defaultDescription(kind),
|
|
419
|
+
workspaceFolder: stringValue(config.workspaceFolder) ||
|
|
420
|
+
(kind === "agent" ? workspaceFolderForProvider(label) : ""),
|
|
421
|
+
systemPrompt: stringValue(config.systemPrompt),
|
|
344
422
|
},
|
|
345
423
|
};
|
|
346
424
|
}
|
|
@@ -353,10 +431,10 @@ function scheduleWorkspaceSave() {
|
|
|
353
431
|
}
|
|
354
432
|
state.saveTimerId = window.setTimeout(() => {
|
|
355
433
|
state.saveTimerId = null;
|
|
356
|
-
void saveWorkspaceNow();
|
|
434
|
+
void saveWorkspaceNow(false);
|
|
357
435
|
}, SAVE_DEBOUNCE_MS);
|
|
358
436
|
}
|
|
359
|
-
async function saveWorkspaceNow() {
|
|
437
|
+
async function saveWorkspaceNow(notify = false) {
|
|
360
438
|
if (!state.isHydrated) {
|
|
361
439
|
return;
|
|
362
440
|
}
|
|
@@ -366,11 +444,41 @@ async function saveWorkspaceNow() {
|
|
|
366
444
|
}
|
|
367
445
|
const saved = await saveWorkspaceState(serializeWorkspace());
|
|
368
446
|
if (saved) {
|
|
447
|
+
state.entities = hydrateEntities(saved.entities);
|
|
369
448
|
state.lastSaveAt = new Date(saved.updatedAt);
|
|
370
449
|
if (selectedEntity()) {
|
|
371
|
-
|
|
450
|
+
syncPanelFromSelection();
|
|
372
451
|
}
|
|
452
|
+
if (notify) {
|
|
453
|
+
showSaveToast("Workspace saved");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
else if (notify) {
|
|
457
|
+
showSaveToast("Save failed");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function showLoadingToast(message) {
|
|
461
|
+
if (toastTimerId) {
|
|
462
|
+
clearTimeout(toastTimerId);
|
|
463
|
+
toastTimerId = null;
|
|
464
|
+
}
|
|
465
|
+
workspaceToast.dataset.state = "loading";
|
|
466
|
+
workspaceToastIcon.textContent = "↻";
|
|
467
|
+
workspaceToastMessage.textContent = message;
|
|
468
|
+
workspaceToast.hidden = false;
|
|
469
|
+
}
|
|
470
|
+
function showSaveToast(message, state = "success") {
|
|
471
|
+
if (toastTimerId) {
|
|
472
|
+
clearTimeout(toastTimerId);
|
|
373
473
|
}
|
|
474
|
+
workspaceToast.dataset.state = state;
|
|
475
|
+
workspaceToastIcon.textContent = state === "error" ? "✕" : "✓";
|
|
476
|
+
workspaceToastMessage.textContent = message;
|
|
477
|
+
workspaceToast.hidden = false;
|
|
478
|
+
toastTimerId = window.setTimeout(() => {
|
|
479
|
+
workspaceToast.hidden = true;
|
|
480
|
+
toastTimerId = null;
|
|
481
|
+
}, SAVE_TOAST_MS);
|
|
374
482
|
}
|
|
375
483
|
function serializeWorkspace() {
|
|
376
484
|
return {
|
|
@@ -400,7 +508,10 @@ function stringValue(input) {
|
|
|
400
508
|
return typeof input === "string" ? input : "";
|
|
401
509
|
}
|
|
402
510
|
function nodeKindValue(input) {
|
|
403
|
-
return input === "system" ||
|
|
511
|
+
return input === "system" ||
|
|
512
|
+
input === "llm" ||
|
|
513
|
+
input === "agent" ||
|
|
514
|
+
input === "mcp"
|
|
404
515
|
? input
|
|
405
516
|
: null;
|
|
406
517
|
}
|
|
@@ -417,12 +528,13 @@ function createEntity(id, label, kind, parentId) {
|
|
|
417
528
|
y: 0,
|
|
418
529
|
angle: 0,
|
|
419
530
|
config: {
|
|
420
|
-
environment: defaultEnvironment(kind),
|
|
421
531
|
endpoint: defaultEndpoint(id, kind),
|
|
422
532
|
token: "",
|
|
423
|
-
|
|
424
|
-
|
|
533
|
+
model: "",
|
|
534
|
+
timeout: 180,
|
|
425
535
|
description: defaultDescription(kind),
|
|
536
|
+
workspaceFolder: kind === "agent" ? workspaceFolderForProvider(label) : "",
|
|
537
|
+
systemPrompt: "",
|
|
426
538
|
},
|
|
427
539
|
};
|
|
428
540
|
}
|
|
@@ -432,21 +544,27 @@ function cloneEntities(entities) {
|
|
|
432
544
|
config: { ...entity.config },
|
|
433
545
|
}));
|
|
434
546
|
}
|
|
435
|
-
function defaultEnvironment(kind) {
|
|
436
|
-
if (kind === "llm" || kind === "mcp") {
|
|
437
|
-
return "production";
|
|
438
|
-
}
|
|
439
|
-
return "local";
|
|
440
|
-
}
|
|
441
547
|
function defaultEndpoint(id, kind) {
|
|
442
548
|
if (kind === "llm") {
|
|
443
|
-
return
|
|
549
|
+
return "http://localhost:1234/v1";
|
|
444
550
|
}
|
|
445
551
|
if (kind === "mcp") {
|
|
446
552
|
return "http://localhost:4321/mcp";
|
|
447
553
|
}
|
|
448
554
|
return "";
|
|
449
555
|
}
|
|
556
|
+
function workspaceFolderForProvider(label) {
|
|
557
|
+
return `AI/WORKSPACE/${slugify(label)}`;
|
|
558
|
+
}
|
|
559
|
+
function slugify(value) {
|
|
560
|
+
const slug = value
|
|
561
|
+
.normalize("NFKD")
|
|
562
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
563
|
+
.toLowerCase()
|
|
564
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
565
|
+
.replace(/^_+|_+$/g, "");
|
|
566
|
+
return slug || "llm_provider";
|
|
567
|
+
}
|
|
450
568
|
function defaultDescription(kind) {
|
|
451
569
|
const copy = {
|
|
452
570
|
system: "Owns the documentation workspace, MCP access, providers, and agent topology.",
|
|
@@ -481,8 +599,7 @@ function addProvider() {
|
|
|
481
599
|
}
|
|
482
600
|
function addAgent(parentId) {
|
|
483
601
|
const hadSelection = Boolean(state.selectedId);
|
|
484
|
-
const index = childrenOf(parentId).filter((entity) => entity.kind === "agent").length +
|
|
485
|
-
1;
|
|
602
|
+
const index = childrenOf(parentId).filter((entity) => entity.kind === "agent").length + 1;
|
|
486
603
|
const id = `agent-${Date.now().toString(36)}`;
|
|
487
604
|
const agent = createEntity(id, `Agent ${index}`, "agent", parentId);
|
|
488
605
|
state.entities = [...state.entities, agent];
|
|
@@ -650,6 +767,9 @@ function collisionRadius(entity) {
|
|
|
650
767
|
function selectEntity(id) {
|
|
651
768
|
const hadSelection = Boolean(state.selectedId);
|
|
652
769
|
state.selectedId = id;
|
|
770
|
+
const entity = id ? entityById(id) : null;
|
|
771
|
+
savedAgentLabel = entity?.kind === "agent" ? entity.label : null;
|
|
772
|
+
savedAgentFolder = entity?.kind === "agent" ? (entity.config.workspaceFolder || workspaceFolderForProvider(entity.label)) : null;
|
|
653
773
|
layoutGraph();
|
|
654
774
|
syncCameraForPanelChange(hadSelection, Boolean(id), true);
|
|
655
775
|
syncPanelFromSelection();
|
|
@@ -693,21 +813,29 @@ function syncPanelFromSelection() {
|
|
|
693
813
|
fallbackPanelHost.hidden = !hasSelection || supportsHtmlInCanvas;
|
|
694
814
|
if (!selected) {
|
|
695
815
|
addButton.title = "Add LLM provider";
|
|
816
|
+
testNodeButton.hidden = true;
|
|
817
|
+
testNodeButton.disabled = true;
|
|
696
818
|
return;
|
|
697
819
|
}
|
|
698
820
|
fields.name.value = selected.label;
|
|
699
821
|
fields.kind.value = selected.kind;
|
|
700
|
-
fields.environment.value = selected.config.environment;
|
|
701
822
|
fields.endpoint.value = selected.config.endpoint;
|
|
702
823
|
fields.token.value = selected.config.token;
|
|
824
|
+
restoreModelSelect(selected.config.model);
|
|
825
|
+
fields.workspaceFolder.value = selected.config.workspaceFolder;
|
|
826
|
+
fields.workspaceField.hidden = selected.kind !== "agent";
|
|
703
827
|
fields.timeout.value = String(selected.config.timeout);
|
|
704
|
-
fields.retryPolicy.value = selected.config.retryPolicy;
|
|
705
828
|
fields.description.value = selected.config.description;
|
|
706
829
|
fields.typeBadge.textContent = labelForBadge(selected.kind);
|
|
707
|
-
fields.healthBadge.textContent = state.lastSaveAt ? "Applied" : "Ready";
|
|
708
830
|
fields.kind.disabled = true;
|
|
831
|
+
fields.llmFields.hidden = selected.kind === "mcp" || selected.kind === "agent";
|
|
709
832
|
fields.mcpInventory.hidden = selected.kind !== "mcp";
|
|
833
|
+
fields.agentSection.hidden = selected.kind !== "agent";
|
|
834
|
+
fields.systemPrompt.value = selected.config.systemPrompt;
|
|
835
|
+
deleteNodeButton.hidden = isProtectedEntity(selected.id);
|
|
710
836
|
deleteNodeButton.disabled = isProtectedEntity(selected.id);
|
|
837
|
+
testNodeButton.hidden = selected.kind !== "llm";
|
|
838
|
+
testNodeButton.disabled = selected.kind !== "llm" || !selected.config.model;
|
|
711
839
|
addButton.title =
|
|
712
840
|
selected.kind === "llm" || entityById(selected.parentId)?.kind === "llm"
|
|
713
841
|
? "Add agent"
|
|
@@ -720,14 +848,136 @@ function syncSelectedFromForm() {
|
|
|
720
848
|
}
|
|
721
849
|
selected.label = fields.name.value.trim() || "Untitled node";
|
|
722
850
|
selected.kind = fields.kind.value;
|
|
723
|
-
selected.config.environment = fields.environment.value;
|
|
724
851
|
selected.config.endpoint = fields.endpoint.value;
|
|
725
852
|
selected.config.token = fields.token.value;
|
|
853
|
+
selected.config.model = fields.model.value;
|
|
726
854
|
selected.config.timeout = Number(fields.timeout.value || 30);
|
|
727
|
-
selected.config.retryPolicy = fields.retryPolicy.value;
|
|
728
855
|
selected.config.description = fields.description.value;
|
|
856
|
+
selected.config.systemPrompt = fields.systemPrompt.value;
|
|
729
857
|
fields.typeBadge.textContent = labelForBadge(selected.kind);
|
|
730
858
|
}
|
|
859
|
+
function restoreModelSelect(savedModel) {
|
|
860
|
+
const existing = Array.from(fields.model.options).map((o) => o.value);
|
|
861
|
+
if (savedModel && !existing.includes(savedModel)) {
|
|
862
|
+
fields.model.innerHTML = "";
|
|
863
|
+
const opt = document.createElement("option");
|
|
864
|
+
opt.value = savedModel;
|
|
865
|
+
opt.textContent = savedModel;
|
|
866
|
+
fields.model.appendChild(opt);
|
|
867
|
+
}
|
|
868
|
+
fields.model.value = savedModel || (existing[0] ?? "");
|
|
869
|
+
}
|
|
870
|
+
async function runAgent() {
|
|
871
|
+
const selected = selectedEntity();
|
|
872
|
+
if (!selected || selected.kind !== "agent")
|
|
873
|
+
return;
|
|
874
|
+
syncSelectedFromForm();
|
|
875
|
+
const llm = entityById(selected.parentId ?? "");
|
|
876
|
+
if (!llm || llm.kind !== "llm") {
|
|
877
|
+
showSaveToast("No parent LLM found for this agent.", "error");
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (!llm.config.model) {
|
|
881
|
+
showSaveToast("Parent LLM has no model selected.", "error");
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (!selected.config.systemPrompt.trim()) {
|
|
885
|
+
showSaveToast("Write a system prompt first.", "error");
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
runAgentButton.disabled = true;
|
|
889
|
+
showLoadingToast("Running agent…");
|
|
890
|
+
const mcpEntity = state.entities.find((e) => e.kind === "mcp");
|
|
891
|
+
const result = await runAgentPrompt({
|
|
892
|
+
endpoint: llm.config.endpoint,
|
|
893
|
+
token: llm.config.token,
|
|
894
|
+
model: llm.config.model,
|
|
895
|
+
systemPrompt: selected.config.systemPrompt,
|
|
896
|
+
userInput: fields.userInput.value.trim() || undefined,
|
|
897
|
+
timeout: llm.config.timeout,
|
|
898
|
+
mcpEndpoint: mcpEntity?.config.endpoint || undefined,
|
|
899
|
+
});
|
|
900
|
+
runAgentButton.disabled = false;
|
|
901
|
+
if (result.ok && result.content) {
|
|
902
|
+
agentResponses.hidden = false;
|
|
903
|
+
const item = document.createElement("div");
|
|
904
|
+
item.className = "agent-response-item";
|
|
905
|
+
const meta = document.createElement("div");
|
|
906
|
+
meta.className = "agent-response-meta";
|
|
907
|
+
meta.textContent = new Date().toLocaleTimeString();
|
|
908
|
+
const content = document.createElement("div");
|
|
909
|
+
content.textContent = result.content;
|
|
910
|
+
item.appendChild(meta);
|
|
911
|
+
item.appendChild(content);
|
|
912
|
+
agentResponses.appendChild(item);
|
|
913
|
+
agentResponses.scrollTop = agentResponses.scrollHeight;
|
|
914
|
+
showSaveToast("Agent responded.", "success");
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
showSaveToast(result.error ?? "Agent failed.", "error");
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
async function loadModelsForSelect() {
|
|
921
|
+
const selected = selectedEntity();
|
|
922
|
+
if (!selected || selected.kind !== "llm")
|
|
923
|
+
return;
|
|
924
|
+
syncSelectedFromForm();
|
|
925
|
+
const endpoint = selected.config.endpoint;
|
|
926
|
+
if (!endpoint) {
|
|
927
|
+
showSaveToast("Set an endpoint first.");
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
loadModelsButton.classList.add("loading");
|
|
931
|
+
loadModelsButton.disabled = true;
|
|
932
|
+
fields.model.disabled = true;
|
|
933
|
+
const result = await listLlmModels({ endpoint, token: selected.config.token });
|
|
934
|
+
loadModelsButton.classList.remove("loading");
|
|
935
|
+
loadModelsButton.disabled = false;
|
|
936
|
+
fields.model.disabled = false;
|
|
937
|
+
if (!result.ok || !result.models?.length) {
|
|
938
|
+
showSaveToast(result.error ?? "No models found.");
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const previousValue = fields.model.value;
|
|
942
|
+
fields.model.innerHTML = "";
|
|
943
|
+
for (const id of result.models) {
|
|
944
|
+
const opt = document.createElement("option");
|
|
945
|
+
opt.value = id;
|
|
946
|
+
opt.textContent = id;
|
|
947
|
+
fields.model.appendChild(opt);
|
|
948
|
+
}
|
|
949
|
+
fields.model.value = result.models.includes(previousValue)
|
|
950
|
+
? previousValue
|
|
951
|
+
: result.models[0];
|
|
952
|
+
selected.config.model = fields.model.value;
|
|
953
|
+
testNodeButton.disabled = !fields.model.value;
|
|
954
|
+
showSaveToast(`${result.models.length} model${result.models.length === 1 ? "" : "s"} loaded.`);
|
|
955
|
+
scheduleRender();
|
|
956
|
+
}
|
|
957
|
+
async function testSelectedLlmConnection() {
|
|
958
|
+
const selected = selectedEntity();
|
|
959
|
+
if (!selected || selected.kind !== "llm") {
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
syncSelectedFromForm();
|
|
963
|
+
testNodeButton.disabled = true;
|
|
964
|
+
showLoadingToast(`Testing ${selected.config.model}…`);
|
|
965
|
+
scheduleRender();
|
|
966
|
+
const result = await testLlmConnection({
|
|
967
|
+
endpoint: selected.config.endpoint,
|
|
968
|
+
token: selected.config.token,
|
|
969
|
+
model: selected.config.model,
|
|
970
|
+
timeout: selected.config.timeout,
|
|
971
|
+
});
|
|
972
|
+
testNodeButton.disabled = false;
|
|
973
|
+
if (result.ok) {
|
|
974
|
+
showSaveToast(result.detail ?? "Connection OK", "success");
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
showSaveToast(result.error || `Connection failed (${result.status || "no status"})`, "error");
|
|
978
|
+
}
|
|
979
|
+
scheduleRender();
|
|
980
|
+
}
|
|
731
981
|
function labelForBadge(kind) {
|
|
732
982
|
const labels = {
|
|
733
983
|
system: "System",
|
|
@@ -901,7 +1151,7 @@ function drawConfigSurface(width, height) {
|
|
|
901
1151
|
return;
|
|
902
1152
|
}
|
|
903
1153
|
const panelWidth = panelWidthForViewport(width);
|
|
904
|
-
const panelHeight = Math.min(PANEL_HEIGHT, height -
|
|
1154
|
+
const panelHeight = Math.min(PANEL_HEIGHT, height - 118);
|
|
905
1155
|
const panelX = width - panelWidth - 24;
|
|
906
1156
|
const panelY = Math.max(24, (height - panelHeight) / 2);
|
|
907
1157
|
try {
|
|
@@ -939,9 +1189,25 @@ function resetView(animate, resetZoom) {
|
|
|
939
1189
|
}
|
|
940
1190
|
function syncCameraForPanelChange(hadSelection, hasSelection, animate) {
|
|
941
1191
|
if (hasSelection) {
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1192
|
+
if (!hadSelection) {
|
|
1193
|
+
// Panel was closed → center selected node in the left area
|
|
1194
|
+
const selected = selectedEntity();
|
|
1195
|
+
if (selected) {
|
|
1196
|
+
const panelReserve = panelWidthForViewport(canvas.clientWidth) + PANEL_GAP;
|
|
1197
|
+
const availableWidth = canvas.clientWidth - panelReserve;
|
|
1198
|
+
const targetX = availableWidth / 2 - selected.x * state.zoom;
|
|
1199
|
+
const appliedDelta = targetX - state.cameraX;
|
|
1200
|
+
// Store negative delta so deselect reverses the shift
|
|
1201
|
+
state.panelShiftX = -appliedDelta;
|
|
1202
|
+
startViewAnimation(targetX, state.cameraY, state.zoom);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
else {
|
|
1206
|
+
// Panel already open → avoid occlusion only if needed
|
|
1207
|
+
const deltaX = selectedPanelAvoidanceDelta();
|
|
1208
|
+
state.panelShiftX += Math.max(0, -deltaX);
|
|
1209
|
+
translateCamera(deltaX, animate);
|
|
1210
|
+
}
|
|
945
1211
|
return;
|
|
946
1212
|
}
|
|
947
1213
|
if (!hadSelection) {
|
|
@@ -1121,8 +1387,7 @@ function rectangularBoundaryPoint(entity, angle) {
|
|
|
1121
1387
|
function isEntityHit(entity, point) {
|
|
1122
1388
|
if (entity.kind === "system" || entity.kind === "llm") {
|
|
1123
1389
|
const radius = entity.kind === "system" ? ROOT_RADIUS : PROVIDER_RADIUS;
|
|
1124
|
-
return (Math.hypot(point.x - entity.x, point.y - entity.y) <=
|
|
1125
|
-
radius + HIT_PADDING);
|
|
1390
|
+
return (Math.hypot(point.x - entity.x, point.y - entity.y) <= radius + HIT_PADDING);
|
|
1126
1391
|
}
|
|
1127
1392
|
const { width, height } = leafSize(entity);
|
|
1128
1393
|
return (Math.abs(point.x - entity.x) <= width / 2 + HIT_PADDING &&
|