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.
@@ -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,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 resetButton = document.getElementById("resetButton");
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
- healthBadge: document.getElementById("nodeHealthBadge"),
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("review-agent", "Code Review Agent", "agent", "devstral"),
79
- createEntity("sonar-agent", "Sonarqube Metrics Agent", "agent", "devstral"),
80
- createEntity("doc-agent", "Documentation Agent", "agent", "qwen"),
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
- selectEntity(hitEntity.id);
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
- scheduleWorkspaceSave();
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
- document.getElementById("testNodeButton").addEventListener("click", () => {
250
- fields.healthBadge.textContent = "Tested";
251
- scheduleRender();
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 ? new Date(persisted.updatedAt) : null;
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 ? [] : [createEntity("root", "Living AI Documentation", "system", null)]),
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" ? "system" : entity.id === "mcp" ? "mcp" : entity.kind;
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
- timeout: finiteNumber(config.timeout, kind === "agent" ? 45 : 30),
342
- retryPolicy: stringValue(config.retryPolicy) || (kind === "llm" ? "exponential" : "linear"),
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
- fields.healthBadge.textContent = "Applied";
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" || input === "llm" || input === "agent" || input === "mcp"
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
- timeout: kind === "agent" ? 45 : 30,
424
- retryPolicy: kind === "llm" ? "exponential" : "linear",
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 `https://api.${id}.example/v1`;
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 - 48);
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
- const deltaX = selectedPanelAvoidanceDelta();
943
- state.panelShiftX += Math.max(0, -deltaX);
944
- translateCamera(deltaX, animate);
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 &&