living-ai-documentation 2.0.0 → 2.3.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.
Files changed (31) hide show
  1. package/dist/src/frontend/agents.js +288 -0
  2. package/dist/src/frontend/config.js +3 -2
  3. package/dist/src/frontend/diagram/color-picker.js +72 -0
  4. package/dist/src/frontend/diagram/defaults-modal.js +302 -0
  5. package/dist/src/frontend/diagram/edge-panel.js +110 -11
  6. package/dist/src/frontend/diagram/main.js +5 -8
  7. package/dist/src/frontend/diagram/network.js +16 -7
  8. package/dist/src/frontend/diagram/node-panel.js +104 -2
  9. package/dist/src/frontend/diagram.html +52 -29
  10. package/dist/src/frontend/i18n/en.json +43 -1
  11. package/dist/src/frontend/i18n/fr.json +43 -1
  12. package/dist/src/frontend/index.html +156 -33
  13. package/dist/src/frontend/workspace/app.js +439 -67
  14. package/dist/src/frontend/workspace/app.js.map +1 -1
  15. package/dist/src/frontend/workspace/app.ts +582 -84
  16. package/dist/src/frontend/workspace/index.html +232 -82
  17. package/dist/src/frontend/workspace/persistence.js +61 -0
  18. package/dist/src/frontend/workspace/persistence.js.map +1 -1
  19. package/dist/src/frontend/workspace/persistence.ts +111 -0
  20. package/dist/src/frontend/workspace/styles.css +293 -34
  21. package/dist/src/lib/config.d.ts +23 -0
  22. package/dist/src/lib/config.d.ts.map +1 -1
  23. package/dist/src/lib/config.js +16 -0
  24. package/dist/src/lib/config.js.map +1 -1
  25. package/dist/src/routes/config.d.ts.map +1 -1
  26. package/dist/src/routes/config.js +13 -0
  27. package/dist/src/routes/config.js.map +1 -1
  28. package/dist/src/routes/workspace.d.ts.map +1 -1
  29. package/dist/src/routes/workspace.js +528 -6
  30. package/dist/src/routes/workspace.js.map +1 -1
  31. 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 = 360;
10
- const PANEL_MAX_WIDTH = 520;
11
- const PANEL_VIEWPORT_RATIO = 1 / 3;
12
- const PANEL_HEIGHT = 650;
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,41 +43,70 @@ 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 resetButton = document.getElementById("resetButton");
52
+ const testNodeButton = document.getElementById("testNodeButton");
53
+ const loadModelsButton = document.getElementById("loadModelsButton");
54
+ const closePanelButton = document.getElementById("closePanelButton");
52
55
  const deleteNodeButton = document.getElementById("deleteNodeButton");
56
+ const renameConfirmOverlay = document.getElementById("renameConfirmOverlay");
57
+ const renameConfirmMessage = document.getElementById("renameConfirmMessage");
58
+ const cancelRenameButton = document.getElementById("cancelRenameButton");
59
+ const confirmRenameButton = document.getElementById("confirmRenameButton");
53
60
  const deleteConfirmOverlay = document.getElementById("deleteConfirmOverlay");
54
61
  const deleteConfirmMessage = document.getElementById("deleteConfirmMessage");
55
62
  const cancelDeleteButton = document.getElementById("cancelDeleteButton");
56
63
  const confirmDeleteButton = document.getElementById("confirmDeleteButton");
64
+ const agentRunOverlay = document.getElementById("agentRunOverlay");
65
+ const agentRunTitle = document.getElementById("agentRunTitle");
66
+ const agentRunHint = document.getElementById("agentRunHint");
67
+ const agentRunInputField = document.getElementById("agentRunInputField");
68
+ const agentRunInput = document.getElementById("agentRunInput");
69
+ const agentRunResult = document.getElementById("agentRunResult");
70
+ const cancelAgentRunButton = document.getElementById("cancelAgentRunButton");
71
+ const executeAgentRunButton = document.getElementById("executeAgentRunButton");
57
72
  const fallbackPanelHost = document.getElementById("fallbackPanelHost");
58
73
  const configSurface = document.getElementById("configSurface");
59
74
  const form = configSurface;
75
+ const workspaceToast = document.getElementById("workspaceToast");
76
+ const workspaceToastIcon = document.getElementById("workspaceToastIcon");
77
+ const workspaceToastMessage = document.getElementById("workspaceToastMessage");
78
+ let toastTimerId = null;
79
+ let savedAgentLabel = null;
80
+ let savedAgentFolder = null;
81
+ let agentRunTargetId = null;
60
82
  const fields = {
61
83
  name: document.getElementById("nodeName"),
62
84
  kind: document.getElementById("nodeKind"),
63
- environment: document.getElementById("nodeEnvironment"),
64
85
  endpoint: document.getElementById("nodeEndpoint"),
65
86
  token: document.getElementById("nodeToken"),
87
+ model: document.getElementById("nodeModel"),
88
+ workspaceField: document.getElementById("agentWorkspaceField"),
89
+ workspaceFolder: document.getElementById("nodeWorkspaceFolder"),
66
90
  timeout: document.getElementById("nodeTimeout"),
67
- retryPolicy: document.getElementById("nodeRetryPolicy"),
68
91
  description: document.getElementById("nodeDescription"),
69
92
  typeBadge: document.getElementById("nodeTypeBadge"),
70
- healthBadge: document.getElementById("nodeHealthBadge"),
93
+ llmFields: document.getElementById("llmFields"),
71
94
  mcpInventory: document.getElementById("mcpInventory"),
95
+ agentSection: document.getElementById("agentSection"),
96
+ systemPrompt: document.getElementById("nodeSystemPrompt"),
97
+ requiresUserInput: document.getElementById("nodeRequiresUserInput"),
98
+ userInputDescriptionField: document.getElementById("agentUserInputDescriptionField"),
99
+ userInputDescription: document.getElementById("nodeUserInputDescription"),
100
+ expectedOutputMarker: document.getElementById("nodeExpectedOutputMarker"),
72
101
  };
73
102
  const initialEntities = [
74
103
  createEntity("root", "Living AI Documentation", "system", null),
75
- createEntity("devstral", "DevStral2 LLM API", "llm", "root"),
76
- createEntity("qwen", "Qwen2 LLM API", "llm", "root"),
77
104
  createEntity("mcp", "MCP", "mcp", "root"),
78
- createEntity("review-agent", "Code Review Agent", "agent", "devstral"),
79
- createEntity("sonar-agent", "Sonarqube Metrics Agent", "agent", "devstral"),
80
- createEntity("doc-agent", "Documentation Agent", "agent", "qwen"),
105
+ //createEntity("devstral", "DevStral2 LLM API", "llm", "root"),
106
+ //createEntity("qwen", "Qwen2 LLM API", "llm", "root"),
107
+ //createEntity("review-agent", "Code Review Agent", "agent", "devstral"),
108
+ //createEntity("sonar-agent", "Sonarqube Metrics Agent", "agent", "devstral"),
109
+ //createEntity("doc-agent", "Documentation Agent", "agent", "qwen"),
81
110
  ];
82
111
  const state = {
83
112
  entities: cloneEntities(initialEntities),
@@ -135,16 +164,6 @@ fitButton.addEventListener("click", () => {
135
164
  scheduleWorkspaceSave();
136
165
  scheduleRender();
137
166
  });
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
167
  canvas.addEventListener("pointerdown", (event) => {
149
168
  if (event.target !== canvas) {
150
169
  return;
@@ -155,7 +174,8 @@ canvas.addEventListener("pointerdown", (event) => {
155
174
  .reverse()
156
175
  .find((entity) => isEntityHit(entity, worldPoint));
157
176
  if (hitEntity) {
158
- selectEntity(hitEntity.id);
177
+ if (hitEntity.id !== "root")
178
+ selectEntity(hitEntity.id);
159
179
  return;
160
180
  }
161
181
  state.isPanning = true;
@@ -212,18 +232,23 @@ form.addEventListener("submit", (event) => {
212
232
  event.preventDefault();
213
233
  syncSelectedFromForm();
214
234
  state.lastSaveAt = new Date();
215
- fields.healthBadge.textContent = "Applied";
216
235
  layoutGraph();
217
- void saveWorkspaceNow();
236
+ void saveWorkspaceNow(true);
218
237
  scheduleRender();
219
238
  });
220
- form.addEventListener("input", () => {
239
+ form.addEventListener("input", (event) => {
221
240
  syncSelectedFromForm();
222
- fields.healthBadge.textContent = "Draft";
223
241
  layoutGraph();
224
- scheduleWorkspaceSave();
242
+ // Don't auto-save while editing the name of an agent — wait for blur + popup
243
+ const isAgentNameEdit = event.target === fields.name && selectedEntity()?.kind === "agent";
244
+ if (!isAgentNameEdit) {
245
+ scheduleWorkspaceSave();
246
+ }
225
247
  scheduleRender();
226
248
  });
249
+ closePanelButton.addEventListener("click", () => {
250
+ selectEntity(null);
251
+ });
227
252
  deleteNodeButton.addEventListener("click", (event) => {
228
253
  event.preventDefault();
229
254
  event.stopPropagation();
@@ -246,9 +271,75 @@ confirmDeleteButton.addEventListener("click", () => {
246
271
  }
247
272
  closeDeleteConfirmation();
248
273
  });
249
- document.getElementById("testNodeButton").addEventListener("click", () => {
250
- fields.healthBadge.textContent = "Tested";
251
- scheduleRender();
274
+ testNodeButton.addEventListener("click", () => {
275
+ const selected = selectedEntity();
276
+ if (selected?.kind === "agent") {
277
+ openAgentRunDialog();
278
+ return;
279
+ }
280
+ void testSelectedLlmConnection();
281
+ });
282
+ loadModelsButton.addEventListener("click", () => {
283
+ void loadModelsForSelect();
284
+ });
285
+ fields.name.addEventListener("blur", () => {
286
+ const selected = selectedEntity();
287
+ if (!selected || selected.kind !== "agent")
288
+ return;
289
+ const newName = fields.name.value.trim();
290
+ if (!newName ||
291
+ !savedAgentLabel ||
292
+ !savedAgentFolder ||
293
+ newName === savedAgentLabel)
294
+ return;
295
+ const newFolder = workspaceFolderForProvider(newName);
296
+ if (newFolder === savedAgentFolder)
297
+ return; // slug identique, pas de renommage nécessaire
298
+ renameConfirmMessage.textContent = `Renommer le dossier "${savedAgentFolder}" en "${newFolder}" ?`;
299
+ renameConfirmOverlay.hidden = false;
300
+ cancelRenameButton.focus();
301
+ });
302
+ cancelRenameButton.addEventListener("click", () => {
303
+ renameConfirmOverlay.hidden = true;
304
+ const selected = selectedEntity();
305
+ if (selected && savedAgentLabel) {
306
+ selected.label = savedAgentLabel;
307
+ fields.name.value = savedAgentLabel;
308
+ layoutGraph();
309
+ scheduleRender();
310
+ }
311
+ });
312
+ confirmRenameButton.addEventListener("click", () => {
313
+ renameConfirmOverlay.hidden = true;
314
+ const selected = selectedEntity();
315
+ if (!selected || !savedAgentFolder)
316
+ return;
317
+ // Remettre l'ancien chemin pour que le backend sache quoi renommer
318
+ selected.config.workspaceFolder = savedAgentFolder;
319
+ fields.workspaceFolder.value = savedAgentFolder;
320
+ const newName = fields.name.value.trim();
321
+ if (newName)
322
+ savedAgentLabel = newName;
323
+ savedAgentFolder = workspaceFolderForProvider(newName);
324
+ scheduleWorkspaceSave();
325
+ });
326
+ fields.model.addEventListener("change", () => {
327
+ testNodeButton.disabled = !fields.model.value;
328
+ const selected = selectedEntity();
329
+ if (selected) {
330
+ selected.config.model = fields.model.value;
331
+ }
332
+ });
333
+ cancelAgentRunButton.addEventListener("click", () => {
334
+ closeAgentRunDialog();
335
+ });
336
+ executeAgentRunButton.addEventListener("click", () => {
337
+ void executeAgentRunFromDialog();
338
+ });
339
+ agentRunOverlay.addEventListener("click", (event) => {
340
+ if (event.target === agentRunOverlay) {
341
+ closeAgentRunDialog();
342
+ }
252
343
  });
253
344
  void initializeWorkspace();
254
345
  function isolatePanelEvents() {
@@ -260,6 +351,8 @@ function isolatePanelEvents() {
260
351
  function isolateConfirmEvents() {
261
352
  for (const type of PANEL_EVENT_TYPES) {
262
353
  deleteConfirmOverlay.addEventListener(type, stopPanelEventPropagation);
354
+ renameConfirmOverlay.addEventListener(type, stopPanelEventPropagation);
355
+ agentRunOverlay.addEventListener(type, stopPanelEventPropagation);
263
356
  }
264
357
  }
265
358
  function stopPanelEventPropagation(event) {
@@ -272,7 +365,9 @@ async function initializeWorkspace() {
272
365
  state.cameraX = finiteNumber(persisted.camera?.x, state.cameraX);
273
366
  state.cameraY = finiteNumber(persisted.camera?.y, state.cameraY);
274
367
  state.zoom = clamp(finiteNumber(persisted.camera?.zoom, state.zoom), ZOOM_MIN, ZOOM_MAX);
275
- state.lastSaveAt = persisted.updatedAt ? new Date(persisted.updatedAt) : null;
368
+ state.lastSaveAt = persisted.updatedAt
369
+ ? new Date(persisted.updatedAt)
370
+ : null;
276
371
  }
277
372
  state.isHydrated = true;
278
373
  layoutGraph();
@@ -289,13 +384,19 @@ function hydrateEntities(entities) {
289
384
  const hasRoot = hydrated.some((entity) => entity.id === "root");
290
385
  const hasMcp = hydrated.some((entity) => entity.id === "mcp");
291
386
  const withRequiredNodes = [
292
- ...(hasRoot ? [] : [createEntity("root", "Living AI Documentation", "system", null)]),
387
+ ...(hasRoot
388
+ ? []
389
+ : [createEntity("root", "Living AI Documentation", "system", null)]),
293
390
  ...hydrated,
294
391
  ...(hasMcp ? [] : [createEntity("mcp", "MCP", "mcp", "root")]),
295
392
  ];
296
393
  const ids = new Set(withRequiredNodes.map((entity) => entity.id));
297
394
  return withRequiredNodes.map((entity) => {
298
- const normalizedKind = entity.id === "root" ? "system" : entity.id === "mcp" ? "mcp" : entity.kind;
395
+ const normalizedKind = entity.id === "root"
396
+ ? "system"
397
+ : entity.id === "mcp"
398
+ ? "mcp"
399
+ : entity.kind;
299
400
  const normalizedParentId = entity.id === "root"
300
401
  ? null
301
402
  : entity.id === "mcp"
@@ -335,12 +436,17 @@ function hydrateEntity(input) {
335
436
  y: finiteNumber(input.y, 0),
336
437
  angle: finiteNumber(input.angle, 0),
337
438
  config: {
338
- environment: stringValue(config.environment) || defaultEnvironment(kind),
339
439
  endpoint: stringValue(config.endpoint) || defaultEndpoint(id, kind),
340
440
  token: stringValue(config.token),
341
- timeout: finiteNumber(config.timeout, kind === "agent" ? 45 : 30),
342
- retryPolicy: stringValue(config.retryPolicy) || (kind === "llm" ? "exponential" : "linear"),
441
+ model: stringValue(config.model),
442
+ timeout: finiteNumber(config.timeout, 180),
343
443
  description: stringValue(config.description) || defaultDescription(kind),
444
+ workspaceFolder: stringValue(config.workspaceFolder) ||
445
+ (kind === "agent" ? workspaceFolderForProvider(label) : ""),
446
+ systemPrompt: stringValue(config.systemPrompt),
447
+ requiresUserInput: config.requiresUserInput === true,
448
+ userInputDescription: stringValue(config.userInputDescription),
449
+ expectedOutputMarker: stringValue(config.expectedOutputMarker),
344
450
  },
345
451
  };
346
452
  }
@@ -353,10 +459,10 @@ function scheduleWorkspaceSave() {
353
459
  }
354
460
  state.saveTimerId = window.setTimeout(() => {
355
461
  state.saveTimerId = null;
356
- void saveWorkspaceNow();
462
+ void saveWorkspaceNow(false);
357
463
  }, SAVE_DEBOUNCE_MS);
358
464
  }
359
- async function saveWorkspaceNow() {
465
+ async function saveWorkspaceNow(notify = false) {
360
466
  if (!state.isHydrated) {
361
467
  return;
362
468
  }
@@ -366,11 +472,41 @@ async function saveWorkspaceNow() {
366
472
  }
367
473
  const saved = await saveWorkspaceState(serializeWorkspace());
368
474
  if (saved) {
475
+ state.entities = hydrateEntities(saved.entities);
369
476
  state.lastSaveAt = new Date(saved.updatedAt);
370
477
  if (selectedEntity()) {
371
- fields.healthBadge.textContent = "Applied";
478
+ syncPanelFromSelection();
479
+ }
480
+ if (notify) {
481
+ showSaveToast("Workspace saved");
372
482
  }
373
483
  }
484
+ else if (notify) {
485
+ showSaveToast("Save failed");
486
+ }
487
+ }
488
+ function showLoadingToast(message) {
489
+ if (toastTimerId) {
490
+ clearTimeout(toastTimerId);
491
+ toastTimerId = null;
492
+ }
493
+ workspaceToast.dataset.state = "loading";
494
+ workspaceToastIcon.textContent = "↻";
495
+ workspaceToastMessage.textContent = message;
496
+ workspaceToast.hidden = false;
497
+ }
498
+ function showSaveToast(message, state = "success") {
499
+ if (toastTimerId) {
500
+ clearTimeout(toastTimerId);
501
+ }
502
+ workspaceToast.dataset.state = state;
503
+ workspaceToastIcon.textContent = state === "error" ? "✕" : "✓";
504
+ workspaceToastMessage.textContent = message;
505
+ workspaceToast.hidden = false;
506
+ toastTimerId = window.setTimeout(() => {
507
+ workspaceToast.hidden = true;
508
+ toastTimerId = null;
509
+ }, SAVE_TOAST_MS);
374
510
  }
375
511
  function serializeWorkspace() {
376
512
  return {
@@ -400,7 +536,10 @@ function stringValue(input) {
400
536
  return typeof input === "string" ? input : "";
401
537
  }
402
538
  function nodeKindValue(input) {
403
- return input === "system" || input === "llm" || input === "agent" || input === "mcp"
539
+ return input === "system" ||
540
+ input === "llm" ||
541
+ input === "agent" ||
542
+ input === "mcp"
404
543
  ? input
405
544
  : null;
406
545
  }
@@ -417,12 +556,16 @@ function createEntity(id, label, kind, parentId) {
417
556
  y: 0,
418
557
  angle: 0,
419
558
  config: {
420
- environment: defaultEnvironment(kind),
421
559
  endpoint: defaultEndpoint(id, kind),
422
560
  token: "",
423
- timeout: kind === "agent" ? 45 : 30,
424
- retryPolicy: kind === "llm" ? "exponential" : "linear",
561
+ model: "",
562
+ timeout: 180,
425
563
  description: defaultDescription(kind),
564
+ workspaceFolder: kind === "agent" ? workspaceFolderForProvider(label) : "",
565
+ systemPrompt: "",
566
+ requiresUserInput: false,
567
+ userInputDescription: "",
568
+ expectedOutputMarker: "",
426
569
  },
427
570
  };
428
571
  }
@@ -432,21 +575,27 @@ function cloneEntities(entities) {
432
575
  config: { ...entity.config },
433
576
  }));
434
577
  }
435
- function defaultEnvironment(kind) {
436
- if (kind === "llm" || kind === "mcp") {
437
- return "production";
438
- }
439
- return "local";
440
- }
441
578
  function defaultEndpoint(id, kind) {
442
579
  if (kind === "llm") {
443
- return `https://api.${id}.example/v1`;
580
+ return "http://localhost:11434";
444
581
  }
445
582
  if (kind === "mcp") {
446
583
  return "http://localhost:4321/mcp";
447
584
  }
448
585
  return "";
449
586
  }
587
+ function workspaceFolderForProvider(label) {
588
+ return `AI/WORKSPACE/${slugify(label)}`;
589
+ }
590
+ function slugify(value) {
591
+ const slug = value
592
+ .normalize("NFKD")
593
+ .replace(/[\u0300-\u036f]/g, "")
594
+ .toLowerCase()
595
+ .replace(/[^a-z0-9]+/g, "_")
596
+ .replace(/^_+|_+$/g, "");
597
+ return slug || "llm_provider";
598
+ }
450
599
  function defaultDescription(kind) {
451
600
  const copy = {
452
601
  system: "Owns the documentation workspace, MCP access, providers, and agent topology.",
@@ -481,8 +630,7 @@ function addProvider() {
481
630
  }
482
631
  function addAgent(parentId) {
483
632
  const hadSelection = Boolean(state.selectedId);
484
- const index = childrenOf(parentId).filter((entity) => entity.kind === "agent").length +
485
- 1;
633
+ const index = childrenOf(parentId).filter((entity) => entity.kind === "agent").length + 1;
486
634
  const id = `agent-${Date.now().toString(36)}`;
487
635
  const agent = createEntity(id, `Agent ${index}`, "agent", parentId);
488
636
  state.entities = [...state.entities, agent];
@@ -650,6 +798,13 @@ function collisionRadius(entity) {
650
798
  function selectEntity(id) {
651
799
  const hadSelection = Boolean(state.selectedId);
652
800
  state.selectedId = id;
801
+ const entity = id ? entityById(id) : null;
802
+ savedAgentLabel = entity?.kind === "agent" ? entity.label : null;
803
+ savedAgentFolder =
804
+ entity?.kind === "agent"
805
+ ? entity.config.workspaceFolder ||
806
+ workspaceFolderForProvider(entity.label)
807
+ : null;
653
808
  layoutGraph();
654
809
  syncCameraForPanelChange(hadSelection, Boolean(id), true);
655
810
  syncPanelFromSelection();
@@ -693,21 +848,38 @@ function syncPanelFromSelection() {
693
848
  fallbackPanelHost.hidden = !hasSelection || supportsHtmlInCanvas;
694
849
  if (!selected) {
695
850
  addButton.title = "Add LLM provider";
851
+ testNodeButton.textContent = "Test";
852
+ testNodeButton.hidden = true;
853
+ testNodeButton.disabled = true;
696
854
  return;
697
855
  }
698
856
  fields.name.value = selected.label;
699
857
  fields.kind.value = selected.kind;
700
- fields.environment.value = selected.config.environment;
701
858
  fields.endpoint.value = selected.config.endpoint;
702
859
  fields.token.value = selected.config.token;
860
+ restoreModelSelect(selected.config.model);
861
+ fields.workspaceFolder.value = selected.config.workspaceFolder;
862
+ fields.workspaceField.hidden = selected.kind !== "agent";
703
863
  fields.timeout.value = String(selected.config.timeout);
704
- fields.retryPolicy.value = selected.config.retryPolicy;
705
864
  fields.description.value = selected.config.description;
706
865
  fields.typeBadge.textContent = labelForBadge(selected.kind);
707
- fields.healthBadge.textContent = state.lastSaveAt ? "Applied" : "Ready";
708
866
  fields.kind.disabled = true;
867
+ fields.llmFields.hidden =
868
+ selected.kind === "mcp" || selected.kind === "agent";
709
869
  fields.mcpInventory.hidden = selected.kind !== "mcp";
710
- deleteNodeButton.disabled = isProtectedEntity(selected.id);
870
+ fields.agentSection.hidden = selected.kind !== "agent";
871
+ fields.systemPrompt.value = selected.config.systemPrompt;
872
+ fields.requiresUserInput.checked = selected.config.requiresUserInput;
873
+ syncUserInputDescriptionVisibility();
874
+ fields.userInputDescription.value = selected.config.userInputDescription;
875
+ fields.expectedOutputMarker.value = selected.config.expectedOutputMarker;
876
+ deleteNodeButton.hidden = isProtectedEntity(selected.id);
877
+ testNodeButton.textContent = "Test";
878
+ testNodeButton.hidden = selected.kind !== "llm" && selected.kind !== "agent";
879
+ testNodeButton.disabled =
880
+ selected.kind === "llm"
881
+ ? !selected.config.model
882
+ : selected.kind !== "agent";
711
883
  addButton.title =
712
884
  selected.kind === "llm" || entityById(selected.parentId)?.kind === "llm"
713
885
  ? "Add agent"
@@ -720,13 +892,198 @@ function syncSelectedFromForm() {
720
892
  }
721
893
  selected.label = fields.name.value.trim() || "Untitled node";
722
894
  selected.kind = fields.kind.value;
723
- selected.config.environment = fields.environment.value;
724
895
  selected.config.endpoint = fields.endpoint.value;
725
896
  selected.config.token = fields.token.value;
897
+ selected.config.model = fields.model.value;
726
898
  selected.config.timeout = Number(fields.timeout.value || 30);
727
- selected.config.retryPolicy = fields.retryPolicy.value;
728
899
  selected.config.description = fields.description.value;
900
+ selected.config.systemPrompt = fields.systemPrompt.value;
901
+ selected.config.requiresUserInput = fields.requiresUserInput.checked;
902
+ selected.config.userInputDescription = fields.userInputDescription.value;
903
+ selected.config.expectedOutputMarker = fields.expectedOutputMarker.value;
904
+ syncUserInputDescriptionVisibility();
729
905
  fields.typeBadge.textContent = labelForBadge(selected.kind);
906
+ // Propagate model + timeout to child agents
907
+ if (selected.kind === "llm") {
908
+ for (const entity of state.entities) {
909
+ if (entity.parentId === selected.id && entity.kind === "agent") {
910
+ entity.config.model = selected.config.model;
911
+ entity.config.timeout = selected.config.timeout;
912
+ }
913
+ }
914
+ }
915
+ }
916
+ function syncUserInputDescriptionVisibility() {
917
+ const shouldShow = selectedEntity()?.kind === "agent" && fields.requiresUserInput.checked;
918
+ fields.userInputDescriptionField.hidden = !shouldShow;
919
+ fields.userInputDescription.required = shouldShow;
920
+ }
921
+ function restoreModelSelect(savedModel) {
922
+ const existing = Array.from(fields.model.options).map((o) => o.value);
923
+ if (savedModel && !existing.includes(savedModel)) {
924
+ fields.model.innerHTML = "";
925
+ const opt = document.createElement("option");
926
+ opt.value = savedModel;
927
+ opt.textContent = savedModel;
928
+ fields.model.appendChild(opt);
929
+ }
930
+ fields.model.value = savedModel || (existing[0] ?? "");
931
+ }
932
+ function openAgentRunDialog() {
933
+ const selected = selectedEntity();
934
+ if (!selected || selected.kind !== "agent")
935
+ return;
936
+ syncSelectedFromForm();
937
+ const llm = entityById(selected.parentId ?? "");
938
+ if (!llm || llm.kind !== "llm") {
939
+ showSaveToast("No parent LLM found for this agent.", "error");
940
+ return;
941
+ }
942
+ if (!llm.config.model) {
943
+ showSaveToast("Parent LLM has no model selected.", "error");
944
+ return;
945
+ }
946
+ if (!selected.config.systemPrompt.trim()) {
947
+ showSaveToast("Write a system prompt first.", "error");
948
+ return;
949
+ }
950
+ if (selected.config.requiresUserInput &&
951
+ !selected.config.userInputDescription.trim()) {
952
+ showSaveToast("Describe the required user input first.", "error");
953
+ return;
954
+ }
955
+ agentRunTargetId = selected.id;
956
+ agentRunTitle.textContent = `Test ${selected.label}`;
957
+ agentRunHint.textContent = selected.config.requiresUserInput
958
+ ? selected.config.userInputDescription
959
+ : "Run this agent with its configured system prompt.";
960
+ agentRunInputField.hidden = !selected.config.requiresUserInput;
961
+ agentRunInput.required = selected.config.requiresUserInput;
962
+ agentRunInput.value = "";
963
+ agentRunResult.hidden = true;
964
+ agentRunResult.textContent = "";
965
+ executeAgentRunButton.disabled = false;
966
+ executeAgentRunButton.textContent = "Run";
967
+ agentRunOverlay.hidden = false;
968
+ if (selected.config.requiresUserInput) {
969
+ window.setTimeout(() => agentRunInput.focus(), 0);
970
+ }
971
+ else {
972
+ window.setTimeout(() => executeAgentRunButton.focus(), 0);
973
+ }
974
+ }
975
+ function closeAgentRunDialog() {
976
+ agentRunOverlay.hidden = true;
977
+ agentRunTargetId = null;
978
+ }
979
+ async function executeAgentRunFromDialog() {
980
+ if (agentRunInput.required && !agentRunInput.value.trim()) {
981
+ agentRunInput.reportValidity();
982
+ return;
983
+ }
984
+ const selected = agentRunTargetId ? entityById(agentRunTargetId) : null;
985
+ if (!selected || selected.kind !== "agent") {
986
+ showAgentRunResult("error", "The selected agent is no longer available.");
987
+ return;
988
+ }
989
+ const llm = entityById(selected.parentId ?? "");
990
+ if (!llm || llm.kind !== "llm") {
991
+ showAgentRunResult("error", "No parent LLM found for this agent.");
992
+ return;
993
+ }
994
+ executeAgentRunButton.disabled = true;
995
+ executeAgentRunButton.textContent = "Running…";
996
+ showAgentRunResult("loading", "Running agent…");
997
+ const mcpEntity = state.entities.find((e) => e.kind === "mcp");
998
+ const result = await runAgentPrompt({
999
+ endpoint: llm.config.endpoint,
1000
+ token: llm.config.token,
1001
+ model: llm.config.model,
1002
+ systemPrompt: selected.config.systemPrompt,
1003
+ userInput: agentRunInput.value.trim() || undefined,
1004
+ timeout: llm.config.timeout,
1005
+ mcpEndpoint: mcpEntity?.config.endpoint || undefined,
1006
+ expectedOutputMarker: selected.config.expectedOutputMarker || undefined,
1007
+ });
1008
+ executeAgentRunButton.disabled = false;
1009
+ executeAgentRunButton.textContent = "Run";
1010
+ if (result.ok && result.content) {
1011
+ showAgentRunResult("success", result.content);
1012
+ showSaveToast("Agent responded.", "success");
1013
+ }
1014
+ else {
1015
+ showAgentRunResult("error", result.error ?? "Agent failed.");
1016
+ showSaveToast(result.error ?? "Agent failed.", "error");
1017
+ }
1018
+ }
1019
+ function showAgentRunResult(state, message) {
1020
+ agentRunResult.hidden = false;
1021
+ agentRunResult.dataset.state = state;
1022
+ agentRunResult.textContent = message;
1023
+ }
1024
+ async function loadModelsForSelect() {
1025
+ const selected = selectedEntity();
1026
+ if (!selected || selected.kind !== "llm")
1027
+ return;
1028
+ syncSelectedFromForm();
1029
+ const endpoint = selected.config.endpoint;
1030
+ if (!endpoint) {
1031
+ showSaveToast("Set an endpoint first.");
1032
+ return;
1033
+ }
1034
+ loadModelsButton.classList.add("loading");
1035
+ loadModelsButton.disabled = true;
1036
+ fields.model.disabled = true;
1037
+ const result = await listLlmModels({
1038
+ endpoint,
1039
+ token: selected.config.token,
1040
+ });
1041
+ loadModelsButton.classList.remove("loading");
1042
+ loadModelsButton.disabled = false;
1043
+ fields.model.disabled = false;
1044
+ if (!result.ok || !result.models?.length) {
1045
+ showSaveToast(result.error ?? "No models found.");
1046
+ return;
1047
+ }
1048
+ const previousValue = fields.model.value;
1049
+ fields.model.innerHTML = "";
1050
+ for (const id of result.models) {
1051
+ const opt = document.createElement("option");
1052
+ opt.value = id;
1053
+ opt.textContent = id;
1054
+ fields.model.appendChild(opt);
1055
+ }
1056
+ fields.model.value = result.models.includes(previousValue)
1057
+ ? previousValue
1058
+ : result.models[0];
1059
+ selected.config.model = fields.model.value;
1060
+ testNodeButton.disabled = !fields.model.value;
1061
+ showSaveToast(`${result.models.length} model${result.models.length === 1 ? "" : "s"} loaded.`);
1062
+ scheduleRender();
1063
+ }
1064
+ async function testSelectedLlmConnection() {
1065
+ const selected = selectedEntity();
1066
+ if (!selected || selected.kind !== "llm") {
1067
+ return;
1068
+ }
1069
+ syncSelectedFromForm();
1070
+ testNodeButton.disabled = true;
1071
+ showLoadingToast(`Testing ${selected.config.model}…`);
1072
+ scheduleRender();
1073
+ const result = await testLlmConnection({
1074
+ endpoint: selected.config.endpoint,
1075
+ token: selected.config.token,
1076
+ model: selected.config.model,
1077
+ timeout: selected.config.timeout,
1078
+ });
1079
+ testNodeButton.disabled = false;
1080
+ if (result.ok) {
1081
+ showSaveToast(result.detail ?? "Connection OK", "success");
1082
+ }
1083
+ else {
1084
+ showSaveToast(result.error || `Connection failed (${result.status || "no status"})`, "error");
1085
+ }
1086
+ scheduleRender();
730
1087
  }
731
1088
  function labelForBadge(kind) {
732
1089
  const labels = {
@@ -901,7 +1258,7 @@ function drawConfigSurface(width, height) {
901
1258
  return;
902
1259
  }
903
1260
  const panelWidth = panelWidthForViewport(width);
904
- const panelHeight = Math.min(PANEL_HEIGHT, height - 48);
1261
+ const panelHeight = Math.min(PANEL_HEIGHT, height - 118);
905
1262
  const panelX = width - panelWidth - 24;
906
1263
  const panelY = Math.max(24, (height - panelHeight) / 2);
907
1264
  try {
@@ -939,9 +1296,25 @@ function resetView(animate, resetZoom) {
939
1296
  }
940
1297
  function syncCameraForPanelChange(hadSelection, hasSelection, animate) {
941
1298
  if (hasSelection) {
942
- const deltaX = selectedPanelAvoidanceDelta();
943
- state.panelShiftX += Math.max(0, -deltaX);
944
- translateCamera(deltaX, animate);
1299
+ if (!hadSelection) {
1300
+ // Panel was closed → center selected node in the left area
1301
+ const selected = selectedEntity();
1302
+ if (selected) {
1303
+ const panelReserve = panelWidthForViewport(canvas.clientWidth) + PANEL_GAP;
1304
+ const availableWidth = canvas.clientWidth - panelReserve;
1305
+ const targetX = availableWidth / 2 - selected.x * state.zoom;
1306
+ const appliedDelta = targetX - state.cameraX;
1307
+ // Store negative delta so deselect reverses the shift
1308
+ state.panelShiftX = -appliedDelta;
1309
+ startViewAnimation(targetX, state.cameraY, state.zoom);
1310
+ }
1311
+ }
1312
+ else {
1313
+ // Panel already open → avoid occlusion only if needed
1314
+ const deltaX = selectedPanelAvoidanceDelta();
1315
+ state.panelShiftX += Math.max(0, -deltaX);
1316
+ translateCamera(deltaX, animate);
1317
+ }
945
1318
  return;
946
1319
  }
947
1320
  if (!hadSelection) {
@@ -1121,8 +1494,7 @@ function rectangularBoundaryPoint(entity, angle) {
1121
1494
  function isEntityHit(entity, point) {
1122
1495
  if (entity.kind === "system" || entity.kind === "llm") {
1123
1496
  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);
1497
+ return (Math.hypot(point.x - entity.x, point.y - entity.y) <= radius + HIT_PADDING);
1126
1498
  }
1127
1499
  const { width, height } = leafSize(entity);
1128
1500
  return (Math.abs(point.x - entity.x) <= width / 2 + HIT_PADDING &&