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,6 +1,9 @@
1
1
  import {
2
2
  loadWorkspaceState,
3
3
  saveWorkspaceState,
4
+ testLlmConnection,
5
+ listLlmModels,
6
+ runAgentPrompt,
4
7
  type PersistedWorkspaceDocument,
5
8
  } from "./persistence.js";
6
9
 
@@ -22,12 +25,16 @@ type WorkspaceCanvasElement = globalThis.HTMLCanvasElement & {
22
25
  type NodeKind = "system" | "llm" | "agent" | "mcp";
23
26
 
24
27
  interface EntityConfig {
25
- environment: string;
26
28
  endpoint: string;
27
29
  token: string;
30
+ model: string;
28
31
  timeout: number;
29
- retryPolicy: string;
30
32
  description: string;
33
+ workspaceFolder: string;
34
+ systemPrompt: string;
35
+ requiresUserInput: boolean;
36
+ userInputDescription: string;
37
+ expectedOutputMarker: string;
31
38
  }
32
39
 
33
40
  interface Entity {
@@ -87,10 +94,10 @@ const PROVIDER_RADIUS = 78;
87
94
  const MIN_POLYGON_SIDES = 6;
88
95
  const GRID_MINOR = 24;
89
96
  const GRID_MAJOR = 96;
90
- const PANEL_MIN_WIDTH = 360;
91
- const PANEL_MAX_WIDTH = 520;
92
- const PANEL_VIEWPORT_RATIO = 1 / 3;
93
- const PANEL_HEIGHT = 650;
97
+ const PANEL_MIN_WIDTH = 540;
98
+ const PANEL_MAX_WIDTH = 780;
99
+ const PANEL_VIEWPORT_RATIO = 1 / 2;
100
+ const PANEL_HEIGHT = 9999;
94
101
  const PANEL_GAP = 44;
95
102
  const ROOT_CHILD_DISTANCE = 255;
96
103
  const PROVIDER_CHILD_DISTANCE = 178;
@@ -125,44 +132,131 @@ const ROOT_CLUSTER_RELAXATION_ATTEMPTS = 7;
125
132
  const ROOT_CLUSTER_MAX_DISTANCE =
126
133
  ROOT_CHILD_DISTANCE + ROOT_CHILD_MAX_EXTRA_DISTANCE + 260;
127
134
  const SAVE_DEBOUNCE_MS = 450;
135
+ const SAVE_TOAST_MS = 2600;
128
136
 
129
- const canvas = document.getElementById("workspaceCanvas") as WorkspaceCanvasElement;
137
+ const canvas = document.getElementById(
138
+ "workspaceCanvas",
139
+ ) as WorkspaceCanvasElement;
130
140
  const ctx = canvas.getContext("2d") as WorkspaceCanvasContext;
131
141
  const apiStatus = document.getElementById("apiStatus") as HTMLElement;
132
142
  const addButton = document.getElementById("addButton") as HTMLButtonElement;
133
143
  const fitButton = document.getElementById("fitButton") as HTMLButtonElement;
134
- const resetButton = document.getElementById("resetButton") as HTMLButtonElement;
135
- const deleteNodeButton = document.getElementById("deleteNodeButton") as HTMLButtonElement;
136
- const deleteConfirmOverlay = document.getElementById("deleteConfirmOverlay") as HTMLElement;
137
- const deleteConfirmMessage = document.getElementById("deleteConfirmMessage") as HTMLElement;
138
- const cancelDeleteButton = document.getElementById("cancelDeleteButton") as HTMLButtonElement;
139
- const confirmDeleteButton = document.getElementById("confirmDeleteButton") as HTMLButtonElement;
140
- const fallbackPanelHost = document.getElementById("fallbackPanelHost") as HTMLElement;
141
- const configSurface = document.getElementById("configSurface") as HTMLFormElement;
144
+ const testNodeButton = document.getElementById(
145
+ "testNodeButton",
146
+ ) as HTMLButtonElement;
147
+ const loadModelsButton = document.getElementById(
148
+ "loadModelsButton",
149
+ ) as HTMLButtonElement;
150
+ const closePanelButton = document.getElementById(
151
+ "closePanelButton",
152
+ ) as HTMLButtonElement;
153
+ const deleteNodeButton = document.getElementById(
154
+ "deleteNodeButton",
155
+ ) as HTMLButtonElement;
156
+ const renameConfirmOverlay = document.getElementById(
157
+ "renameConfirmOverlay",
158
+ ) as HTMLElement;
159
+ const renameConfirmMessage = document.getElementById(
160
+ "renameConfirmMessage",
161
+ ) as HTMLElement;
162
+ const cancelRenameButton = document.getElementById(
163
+ "cancelRenameButton",
164
+ ) as HTMLButtonElement;
165
+ const confirmRenameButton = document.getElementById(
166
+ "confirmRenameButton",
167
+ ) as HTMLButtonElement;
168
+ const deleteConfirmOverlay = document.getElementById(
169
+ "deleteConfirmOverlay",
170
+ ) as HTMLElement;
171
+ const deleteConfirmMessage = document.getElementById(
172
+ "deleteConfirmMessage",
173
+ ) as HTMLElement;
174
+ const cancelDeleteButton = document.getElementById(
175
+ "cancelDeleteButton",
176
+ ) as HTMLButtonElement;
177
+ const confirmDeleteButton = document.getElementById(
178
+ "confirmDeleteButton",
179
+ ) as HTMLButtonElement;
180
+ const agentRunOverlay = document.getElementById(
181
+ "agentRunOverlay",
182
+ ) as HTMLElement;
183
+ const agentRunTitle = document.getElementById("agentRunTitle") as HTMLElement;
184
+ const agentRunHint = document.getElementById("agentRunHint") as HTMLElement;
185
+ const agentRunInputField = document.getElementById(
186
+ "agentRunInputField",
187
+ ) as HTMLElement;
188
+ const agentRunInput = document.getElementById(
189
+ "agentRunInput",
190
+ ) as HTMLTextAreaElement;
191
+ const agentRunResult = document.getElementById("agentRunResult") as HTMLElement;
192
+ const cancelAgentRunButton = document.getElementById(
193
+ "cancelAgentRunButton",
194
+ ) as HTMLButtonElement;
195
+ const executeAgentRunButton = document.getElementById(
196
+ "executeAgentRunButton",
197
+ ) as HTMLButtonElement;
198
+ const fallbackPanelHost = document.getElementById(
199
+ "fallbackPanelHost",
200
+ ) as HTMLElement;
201
+ const configSurface = document.getElementById(
202
+ "configSurface",
203
+ ) as HTMLFormElement;
142
204
  const form = configSurface;
205
+ const workspaceToast = document.getElementById("workspaceToast") as HTMLElement;
206
+ const workspaceToastIcon = document.getElementById(
207
+ "workspaceToastIcon",
208
+ ) as HTMLElement;
209
+ const workspaceToastMessage = document.getElementById(
210
+ "workspaceToastMessage",
211
+ ) as HTMLElement;
212
+ let toastTimerId: number | null = null;
213
+ let savedAgentLabel: string | null = null;
214
+ let savedAgentFolder: string | null = null;
215
+ let agentRunTargetId: string | null = null;
143
216
 
144
217
  const fields = {
145
218
  name: document.getElementById("nodeName") as HTMLInputElement,
146
219
  kind: document.getElementById("nodeKind") as HTMLSelectElement,
147
- environment: document.getElementById("nodeEnvironment") as HTMLSelectElement,
148
220
  endpoint: document.getElementById("nodeEndpoint") as HTMLInputElement,
149
221
  token: document.getElementById("nodeToken") as HTMLInputElement,
222
+ model: document.getElementById("nodeModel") as HTMLSelectElement,
223
+ workspaceField: document.getElementById("agentWorkspaceField") as HTMLElement,
224
+ workspaceFolder: document.getElementById(
225
+ "nodeWorkspaceFolder",
226
+ ) as HTMLInputElement,
150
227
  timeout: document.getElementById("nodeTimeout") as HTMLInputElement,
151
- retryPolicy: document.getElementById("nodeRetryPolicy") as HTMLSelectElement,
152
- description: document.getElementById("nodeDescription") as HTMLTextAreaElement,
228
+ description: document.getElementById(
229
+ "nodeDescription",
230
+ ) as HTMLTextAreaElement,
153
231
  typeBadge: document.getElementById("nodeTypeBadge") as HTMLElement,
154
- healthBadge: document.getElementById("nodeHealthBadge") as HTMLElement,
232
+ llmFields: document.getElementById("llmFields") as HTMLElement,
155
233
  mcpInventory: document.getElementById("mcpInventory") as HTMLElement,
234
+ agentSection: document.getElementById("agentSection") as HTMLElement,
235
+ systemPrompt: document.getElementById(
236
+ "nodeSystemPrompt",
237
+ ) as HTMLTextAreaElement,
238
+ requiresUserInput: document.getElementById(
239
+ "nodeRequiresUserInput",
240
+ ) as HTMLInputElement,
241
+ userInputDescriptionField: document.getElementById(
242
+ "agentUserInputDescriptionField",
243
+ ) as HTMLElement,
244
+ userInputDescription: document.getElementById(
245
+ "nodeUserInputDescription",
246
+ ) as HTMLTextAreaElement,
247
+ expectedOutputMarker: document.getElementById(
248
+ "nodeExpectedOutputMarker",
249
+ ) as HTMLInputElement,
156
250
  };
157
251
 
158
252
  const initialEntities: Entity[] = [
159
253
  createEntity("root", "Living AI Documentation", "system", null),
160
- createEntity("devstral", "DevStral2 LLM API", "llm", "root"),
161
- createEntity("qwen", "Qwen2 LLM API", "llm", "root"),
162
254
  createEntity("mcp", "MCP", "mcp", "root"),
163
- createEntity("review-agent", "Code Review Agent", "agent", "devstral"),
164
- createEntity("sonar-agent", "Sonarqube Metrics Agent", "agent", "devstral"),
165
- createEntity("doc-agent", "Documentation Agent", "agent", "qwen"),
255
+ //createEntity("devstral", "DevStral2 LLM API", "llm", "root"),
256
+ //createEntity("qwen", "Qwen2 LLM API", "llm", "root"),
257
+ //createEntity("review-agent", "Code Review Agent", "agent", "devstral"),
258
+ //createEntity("sonar-agent", "Sonarqube Metrics Agent", "agent", "devstral"),
259
+ //createEntity("doc-agent", "Documentation Agent", "agent", "qwen"),
166
260
  ];
167
261
 
168
262
  const state: WorkspaceState = {
@@ -230,17 +324,6 @@ fitButton.addEventListener("click", () => {
230
324
  scheduleRender();
231
325
  });
232
326
 
233
- resetButton.addEventListener("click", () => {
234
- state.entities = cloneEntities(initialEntities);
235
- state.selectedId = null;
236
- state.lastSaveAt = null;
237
- layoutGraph();
238
- resetView(true, true);
239
- syncPanelFromSelection();
240
- scheduleWorkspaceSave();
241
- scheduleRender();
242
- });
243
-
244
327
  canvas.addEventListener("pointerdown", (event) => {
245
328
  if (event.target !== canvas) {
246
329
  return;
@@ -253,7 +336,7 @@ canvas.addEventListener("pointerdown", (event) => {
253
336
  .find((entity) => isEntityHit(entity, worldPoint));
254
337
 
255
338
  if (hitEntity) {
256
- selectEntity(hitEntity.id);
339
+ if (hitEntity.id !== "root") selectEntity(hitEntity.id);
257
340
  return;
258
341
  }
259
342
 
@@ -327,20 +410,28 @@ form.addEventListener("submit", (event) => {
327
410
  event.preventDefault();
328
411
  syncSelectedFromForm();
329
412
  state.lastSaveAt = new Date();
330
- fields.healthBadge.textContent = "Applied";
413
+
331
414
  layoutGraph();
332
- void saveWorkspaceNow();
415
+ void saveWorkspaceNow(true);
333
416
  scheduleRender();
334
417
  });
335
418
 
336
- form.addEventListener("input", () => {
419
+ form.addEventListener("input", (event) => {
337
420
  syncSelectedFromForm();
338
- fields.healthBadge.textContent = "Draft";
339
421
  layoutGraph();
340
- scheduleWorkspaceSave();
422
+ // Don't auto-save while editing the name of an agent — wait for blur + popup
423
+ const isAgentNameEdit =
424
+ event.target === fields.name && selectedEntity()?.kind === "agent";
425
+ if (!isAgentNameEdit) {
426
+ scheduleWorkspaceSave();
427
+ }
341
428
  scheduleRender();
342
429
  });
343
430
 
431
+ closePanelButton.addEventListener("click", () => {
432
+ selectEntity(null);
433
+ });
434
+
344
435
  deleteNodeButton.addEventListener("click", (event) => {
345
436
  event.preventDefault();
346
437
  event.stopPropagation();
@@ -367,9 +458,84 @@ confirmDeleteButton.addEventListener("click", () => {
367
458
  closeDeleteConfirmation();
368
459
  });
369
460
 
370
- document.getElementById("testNodeButton").addEventListener("click", () => {
371
- fields.healthBadge.textContent = "Tested";
372
- scheduleRender();
461
+ testNodeButton.addEventListener("click", () => {
462
+ const selected = selectedEntity();
463
+ if (selected?.kind === "agent") {
464
+ openAgentRunDialog();
465
+ return;
466
+ }
467
+
468
+ void testSelectedLlmConnection();
469
+ });
470
+
471
+ loadModelsButton.addEventListener("click", () => {
472
+ void loadModelsForSelect();
473
+ });
474
+
475
+ fields.name.addEventListener("blur", () => {
476
+ const selected = selectedEntity();
477
+ if (!selected || selected.kind !== "agent") return;
478
+ const newName = fields.name.value.trim();
479
+ if (
480
+ !newName ||
481
+ !savedAgentLabel ||
482
+ !savedAgentFolder ||
483
+ newName === savedAgentLabel
484
+ )
485
+ return;
486
+
487
+ const newFolder = workspaceFolderForProvider(newName);
488
+ if (newFolder === savedAgentFolder) return; // slug identique, pas de renommage nécessaire
489
+
490
+ renameConfirmMessage.textContent = `Renommer le dossier "${savedAgentFolder}" en "${newFolder}" ?`;
491
+ renameConfirmOverlay.hidden = false;
492
+ cancelRenameButton.focus();
493
+ });
494
+
495
+ cancelRenameButton.addEventListener("click", () => {
496
+ renameConfirmOverlay.hidden = true;
497
+ const selected = selectedEntity();
498
+ if (selected && savedAgentLabel) {
499
+ selected.label = savedAgentLabel;
500
+ fields.name.value = savedAgentLabel;
501
+ layoutGraph();
502
+ scheduleRender();
503
+ }
504
+ });
505
+
506
+ confirmRenameButton.addEventListener("click", () => {
507
+ renameConfirmOverlay.hidden = true;
508
+ const selected = selectedEntity();
509
+ if (!selected || !savedAgentFolder) return;
510
+ // Remettre l'ancien chemin pour que le backend sache quoi renommer
511
+ selected.config.workspaceFolder = savedAgentFolder;
512
+ fields.workspaceFolder.value = savedAgentFolder;
513
+ const newName = fields.name.value.trim();
514
+ if (newName) savedAgentLabel = newName;
515
+ savedAgentFolder = workspaceFolderForProvider(newName);
516
+ scheduleWorkspaceSave();
517
+ });
518
+
519
+ fields.model.addEventListener("change", () => {
520
+ testNodeButton.disabled = !fields.model.value;
521
+ const selected = selectedEntity();
522
+ if (selected) {
523
+ selected.config.model = fields.model.value;
524
+ }
525
+ });
526
+
527
+ cancelAgentRunButton.addEventListener("click", () => {
528
+ closeAgentRunDialog();
529
+ });
530
+
531
+ executeAgentRunButton.addEventListener("click", () => {
532
+ void executeAgentRunFromDialog();
533
+ });
534
+
535
+ agentRunOverlay.addEventListener("click", (event) => {
536
+ if (event.target === agentRunOverlay) {
537
+ closeAgentRunDialog();
538
+ }
373
539
  });
374
540
 
375
541
  void initializeWorkspace();
@@ -384,6 +550,8 @@ function isolatePanelEvents() {
384
550
  function isolateConfirmEvents() {
385
551
  for (const type of PANEL_EVENT_TYPES) {
386
552
  deleteConfirmOverlay.addEventListener(type, stopPanelEventPropagation);
553
+ renameConfirmOverlay.addEventListener(type, stopPanelEventPropagation);
554
+ agentRunOverlay.addEventListener(type, stopPanelEventPropagation);
387
555
  }
388
556
  }
389
557
 
@@ -397,8 +565,14 @@ async function initializeWorkspace() {
397
565
  state.entities = hydrateEntities(persisted.entities);
398
566
  state.cameraX = finiteNumber(persisted.camera?.x, state.cameraX);
399
567
  state.cameraY = finiteNumber(persisted.camera?.y, state.cameraY);
400
- state.zoom = clamp(finiteNumber(persisted.camera?.zoom, state.zoom), ZOOM_MIN, ZOOM_MAX);
401
- state.lastSaveAt = persisted.updatedAt ? new Date(persisted.updatedAt) : null;
568
+ state.zoom = clamp(
569
+ finiteNumber(persisted.camera?.zoom, state.zoom),
570
+ ZOOM_MIN,
571
+ ZOOM_MAX,
572
+ );
573
+ state.lastSaveAt = persisted.updatedAt
574
+ ? new Date(persisted.updatedAt)
575
+ : null;
402
576
  }
403
577
 
404
578
  state.isHydrated = true;
@@ -417,14 +591,21 @@ function hydrateEntities(entities: unknown[]) {
417
591
  const hasRoot = hydrated.some((entity) => entity.id === "root");
418
592
  const hasMcp = hydrated.some((entity) => entity.id === "mcp");
419
593
  const withRequiredNodes = [
420
- ...(hasRoot ? [] : [createEntity("root", "Living AI Documentation", "system", null)]),
594
+ ...(hasRoot
595
+ ? []
596
+ : [createEntity("root", "Living AI Documentation", "system", null)]),
421
597
  ...hydrated,
422
598
  ...(hasMcp ? [] : [createEntity("mcp", "MCP", "mcp", "root")]),
423
599
  ];
424
600
  const ids = new Set(withRequiredNodes.map((entity) => entity.id));
425
601
 
426
602
  return withRequiredNodes.map((entity) => {
427
- const normalizedKind = entity.id === "root" ? "system" : entity.id === "mcp" ? "mcp" : entity.kind;
603
+ const normalizedKind =
604
+ entity.id === "root"
605
+ ? "system"
606
+ : entity.id === "mcp"
607
+ ? "mcp"
608
+ : entity.kind;
428
609
  const normalizedParentId =
429
610
  entity.id === "root"
430
611
  ? null
@@ -439,7 +620,12 @@ function hydrateEntities(entities: unknown[]) {
439
620
  kind: normalizedKind,
440
621
  parentId: normalizedParentId,
441
622
  config: {
442
- ...createEntity(entity.id, entity.label, normalizedKind, normalizedParentId).config,
623
+ ...createEntity(
624
+ entity.id,
625
+ entity.label,
626
+ normalizedKind,
627
+ normalizedParentId,
628
+ ).config,
443
629
  ...entity.config,
444
630
  },
445
631
  };
@@ -468,12 +654,18 @@ function hydrateEntity(input: unknown): Entity | null {
468
654
  y: finiteNumber(input.y, 0),
469
655
  angle: finiteNumber(input.angle, 0),
470
656
  config: {
471
- environment: stringValue(config.environment) || defaultEnvironment(kind),
472
657
  endpoint: stringValue(config.endpoint) || defaultEndpoint(id, kind),
473
658
  token: stringValue(config.token),
474
- timeout: finiteNumber(config.timeout, kind === "agent" ? 45 : 30),
475
- retryPolicy: stringValue(config.retryPolicy) || (kind === "llm" ? "exponential" : "linear"),
659
+ model: stringValue(config.model),
660
+ timeout: finiteNumber(config.timeout, 180),
476
661
  description: stringValue(config.description) || defaultDescription(kind),
662
+ workspaceFolder:
663
+ stringValue(config.workspaceFolder) ||
664
+ (kind === "agent" ? workspaceFolderForProvider(label) : ""),
665
+ systemPrompt: stringValue(config.systemPrompt),
666
+ requiresUserInput: config.requiresUserInput === true,
667
+ userInputDescription: stringValue(config.userInputDescription),
668
+ expectedOutputMarker: stringValue(config.expectedOutputMarker),
477
669
  },
478
670
  };
479
671
  }
@@ -487,11 +679,11 @@ function scheduleWorkspaceSave() {
487
679
  }
488
680
  state.saveTimerId = window.setTimeout(() => {
489
681
  state.saveTimerId = null;
490
- void saveWorkspaceNow();
682
+ void saveWorkspaceNow(false);
491
683
  }, SAVE_DEBOUNCE_MS);
492
684
  }
493
685
 
494
- async function saveWorkspaceNow() {
686
+ async function saveWorkspaceNow(notify = false) {
495
687
  if (!state.isHydrated) {
496
688
  return;
497
689
  }
@@ -502,11 +694,45 @@ async function saveWorkspaceNow() {
502
694
 
503
695
  const saved = await saveWorkspaceState(serializeWorkspace());
504
696
  if (saved) {
697
+ state.entities = hydrateEntities(saved.entities);
505
698
  state.lastSaveAt = new Date(saved.updatedAt);
506
699
  if (selectedEntity()) {
507
- fields.healthBadge.textContent = "Applied";
700
+ syncPanelFromSelection();
508
701
  }
702
+ if (notify) {
703
+ showSaveToast("Workspace saved");
704
+ }
705
+ } else if (notify) {
706
+ showSaveToast("Save failed");
707
+ }
708
+ }
709
+
710
+ function showLoadingToast(message: string) {
711
+ if (toastTimerId) {
712
+ clearTimeout(toastTimerId);
713
+ toastTimerId = null;
714
+ }
715
+ workspaceToast.dataset.state = "loading";
716
+ workspaceToastIcon.textContent = "↻";
717
+ workspaceToastMessage.textContent = message;
718
+ workspaceToast.hidden = false;
719
+ }
720
+
721
+ function showSaveToast(
722
+ message: string,
723
+ state: "success" | "error" = "success",
724
+ ) {
725
+ if (toastTimerId) {
726
+ clearTimeout(toastTimerId);
509
727
  }
728
+ workspaceToast.dataset.state = state;
729
+ workspaceToastIcon.textContent = state === "error" ? "✕" : "✓";
730
+ workspaceToastMessage.textContent = message;
731
+ workspaceToast.hidden = false;
732
+ toastTimerId = window.setTimeout(() => {
733
+ workspaceToast.hidden = true;
734
+ toastTimerId = null;
735
+ }, SAVE_TOAST_MS);
510
736
  }
511
737
 
512
738
  function serializeWorkspace(): PersistedWorkspaceDocument {
@@ -540,7 +766,10 @@ function stringValue(input: unknown) {
540
766
  }
541
767
 
542
768
  function nodeKindValue(input: unknown): NodeKind | null {
543
- return input === "system" || input === "llm" || input === "agent" || input === "mcp"
769
+ return input === "system" ||
770
+ input === "llm" ||
771
+ input === "agent" ||
772
+ input === "mcp"
544
773
  ? input
545
774
  : null;
546
775
  }
@@ -559,12 +788,17 @@ function createEntity(id, label, kind, parentId) {
559
788
  y: 0,
560
789
  angle: 0,
561
790
  config: {
562
- environment: defaultEnvironment(kind),
563
791
  endpoint: defaultEndpoint(id, kind),
564
792
  token: "",
565
- timeout: kind === "agent" ? 45 : 30,
566
- retryPolicy: kind === "llm" ? "exponential" : "linear",
793
+ model: "",
794
+ timeout: 180,
567
795
  description: defaultDescription(kind),
796
+ workspaceFolder:
797
+ kind === "agent" ? workspaceFolderForProvider(label) : "",
798
+ systemPrompt: "",
799
+ requiresUserInput: false,
800
+ userInputDescription: "",
801
+ expectedOutputMarker: "",
568
802
  },
569
803
  };
570
804
  }
@@ -576,16 +810,9 @@ function cloneEntities(entities) {
576
810
  }));
577
811
  }
578
812
 
579
- function defaultEnvironment(kind) {
580
- if (kind === "llm" || kind === "mcp") {
581
- return "production";
582
- }
583
- return "local";
584
- }
585
-
586
813
  function defaultEndpoint(id, kind) {
587
814
  if (kind === "llm") {
588
- return `https://api.${id}.example/v1`;
815
+ return "http://localhost:11434";
589
816
  }
590
817
  if (kind === "mcp") {
591
818
  return "http://localhost:4321/mcp";
@@ -593,6 +820,20 @@ function defaultEndpoint(id, kind) {
593
820
  return "";
594
821
  }
595
822
 
823
+ function workspaceFolderForProvider(label) {
824
+ return `AI/WORKSPACE/${slugify(label)}`;
825
+ }
826
+
827
+ function slugify(value) {
828
+ const slug = value
829
+ .normalize("NFKD")
830
+ .replace(/[\u0300-\u036f]/g, "")
831
+ .toLowerCase()
832
+ .replace(/[^a-z0-9]+/g, "_")
833
+ .replace(/^_+|_+$/g, "");
834
+ return slug || "llm_provider";
835
+ }
836
+
596
837
  function defaultDescription(kind) {
597
838
  const copy = {
598
839
  system:
@@ -637,8 +878,7 @@ function addProvider() {
637
878
  function addAgent(parentId) {
638
879
  const hadSelection = Boolean(state.selectedId);
639
880
  const index =
640
- childrenOf(parentId).filter((entity) => entity.kind === "agent").length +
641
- 1;
881
+ childrenOf(parentId).filter((entity) => entity.kind === "agent").length + 1;
642
882
  const id = `agent-${Date.now().toString(36)}`;
643
883
  const agent = createEntity(id, `Agent ${index}`, "agent", parentId);
644
884
  state.entities = [...state.entities, agent];
@@ -821,8 +1061,7 @@ function distanceForChildren(
821
1061
  includeSubtree ? subtreeRadius(child) : collisionRadius(child),
822
1062
  ),
823
1063
  );
824
- const parentClearance =
825
- parentRadius + largestChildRadius + NODE_SPACING_GAP;
1064
+ const parentClearance = parentRadius + largestChildRadius + NODE_SPACING_GAP;
826
1065
 
827
1066
  if (children.length === 1) {
828
1067
  return Math.max(minimumDistance, parentClearance);
@@ -900,6 +1139,13 @@ function collisionRadius(entity) {
900
1139
  function selectEntity(id) {
901
1140
  const hadSelection = Boolean(state.selectedId);
902
1141
  state.selectedId = id;
1142
+ const entity = id ? entityById(id) : null;
1143
+ savedAgentLabel = entity?.kind === "agent" ? entity.label : null;
1144
+ savedAgentFolder =
1145
+ entity?.kind === "agent"
1146
+ ? entity.config.workspaceFolder ||
1147
+ workspaceFolderForProvider(entity.label)
1148
+ : null;
903
1149
  layoutGraph();
904
1150
  syncCameraForPanelChange(hadSelection, Boolean(id), true);
905
1151
  syncPanelFromSelection();
@@ -952,22 +1198,39 @@ function syncPanelFromSelection() {
952
1198
 
953
1199
  if (!selected) {
954
1200
  addButton.title = "Add LLM provider";
1201
+ testNodeButton.textContent = "Test";
1202
+ testNodeButton.hidden = true;
1203
+ testNodeButton.disabled = true;
955
1204
  return;
956
1205
  }
957
1206
 
958
1207
  fields.name.value = selected.label;
959
1208
  fields.kind.value = selected.kind;
960
- fields.environment.value = selected.config.environment;
961
1209
  fields.endpoint.value = selected.config.endpoint;
962
1210
  fields.token.value = selected.config.token;
1211
+ restoreModelSelect(selected.config.model);
1212
+ fields.workspaceFolder.value = selected.config.workspaceFolder;
1213
+ fields.workspaceField.hidden = selected.kind !== "agent";
963
1214
  fields.timeout.value = String(selected.config.timeout);
964
- fields.retryPolicy.value = selected.config.retryPolicy;
965
1215
  fields.description.value = selected.config.description;
966
1216
  fields.typeBadge.textContent = labelForBadge(selected.kind);
967
- fields.healthBadge.textContent = state.lastSaveAt ? "Applied" : "Ready";
968
1217
  fields.kind.disabled = true;
1218
+ fields.llmFields.hidden =
1219
+ selected.kind === "mcp" || selected.kind === "agent";
969
1220
  fields.mcpInventory.hidden = selected.kind !== "mcp";
970
- deleteNodeButton.disabled = isProtectedEntity(selected.id);
1221
+ fields.agentSection.hidden = selected.kind !== "agent";
1222
+ fields.systemPrompt.value = selected.config.systemPrompt;
1223
+ fields.requiresUserInput.checked = selected.config.requiresUserInput;
1224
+ syncUserInputDescriptionVisibility();
1225
+ fields.userInputDescription.value = selected.config.userInputDescription;
1226
+ fields.expectedOutputMarker.value = selected.config.expectedOutputMarker;
1227
+ deleteNodeButton.hidden = isProtectedEntity(selected.id);
1228
+ testNodeButton.textContent = "Test";
1229
+ testNodeButton.hidden = selected.kind !== "llm" && selected.kind !== "agent";
1230
+ testNodeButton.disabled =
1231
+ selected.kind === "llm"
1232
+ ? !selected.config.model
1233
+ : selected.kind !== "agent";
971
1234
  addButton.title =
972
1235
  selected.kind === "llm" || entityById(selected.parentId)?.kind === "llm"
973
1236
  ? "Add agent"
@@ -982,13 +1245,232 @@ function syncSelectedFromForm() {
982
1245
 
983
1246
  selected.label = fields.name.value.trim() || "Untitled node";
984
1247
  selected.kind = fields.kind.value as NodeKind;
985
- selected.config.environment = fields.environment.value;
986
1248
  selected.config.endpoint = fields.endpoint.value;
987
1249
  selected.config.token = fields.token.value;
1250
+ selected.config.model = fields.model.value;
988
1251
  selected.config.timeout = Number(fields.timeout.value || 30);
989
- selected.config.retryPolicy = fields.retryPolicy.value;
990
1252
  selected.config.description = fields.description.value;
1253
+ selected.config.systemPrompt = fields.systemPrompt.value;
1254
+ selected.config.requiresUserInput = fields.requiresUserInput.checked;
1255
+ selected.config.userInputDescription = fields.userInputDescription.value;
1256
+ selected.config.expectedOutputMarker = fields.expectedOutputMarker.value;
1257
+ syncUserInputDescriptionVisibility();
991
1258
  fields.typeBadge.textContent = labelForBadge(selected.kind);
1259
+
1260
+ // Propagate model + timeout to child agents
1261
+ if (selected.kind === "llm") {
1262
+ for (const entity of state.entities) {
1263
+ if (entity.parentId === selected.id && entity.kind === "agent") {
1264
+ entity.config.model = selected.config.model;
1265
+ entity.config.timeout = selected.config.timeout;
1266
+ }
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ function syncUserInputDescriptionVisibility() {
1272
+ const shouldShow =
1273
+ selectedEntity()?.kind === "agent" && fields.requiresUserInput.checked;
1274
+ fields.userInputDescriptionField.hidden = !shouldShow;
1275
+ fields.userInputDescription.required = shouldShow;
1276
+ }
1277
+
1278
+ function restoreModelSelect(savedModel: string) {
1279
+ const existing = Array.from(fields.model.options).map((o) => o.value);
1280
+ if (savedModel && !existing.includes(savedModel)) {
1281
+ fields.model.innerHTML = "";
1282
+ const opt = document.createElement("option");
1283
+ opt.value = savedModel;
1284
+ opt.textContent = savedModel;
1285
+ fields.model.appendChild(opt);
1286
+ }
1287
+ fields.model.value = savedModel || (existing[0] ?? "");
1288
+ }
1289
+
1290
+ function openAgentRunDialog() {
1291
+ const selected = selectedEntity();
1292
+ if (!selected || selected.kind !== "agent") return;
1293
+
1294
+ syncSelectedFromForm();
1295
+
1296
+ const llm = entityById(selected.parentId ?? "");
1297
+ if (!llm || llm.kind !== "llm") {
1298
+ showSaveToast("No parent LLM found for this agent.", "error");
1299
+ return;
1300
+ }
1301
+ if (!llm.config.model) {
1302
+ showSaveToast("Parent LLM has no model selected.", "error");
1303
+ return;
1304
+ }
1305
+ if (!selected.config.systemPrompt.trim()) {
1306
+ showSaveToast("Write a system prompt first.", "error");
1307
+ return;
1308
+ }
1309
+ if (
1310
+ selected.config.requiresUserInput &&
1311
+ !selected.config.userInputDescription.trim()
1312
+ ) {
1313
+ showSaveToast("Describe the required user input first.", "error");
1314
+ return;
1315
+ }
1316
+
1317
+ agentRunTargetId = selected.id;
1318
+ agentRunTitle.textContent = `Test ${selected.label}`;
1319
+ agentRunHint.textContent = selected.config.requiresUserInput
1320
+ ? selected.config.userInputDescription
1321
+ : "Run this agent with its configured system prompt.";
1322
+ agentRunInputField.hidden = !selected.config.requiresUserInput;
1323
+ agentRunInput.required = selected.config.requiresUserInput;
1324
+ agentRunInput.value = "";
1325
+ agentRunResult.hidden = true;
1326
+ agentRunResult.textContent = "";
1327
+ executeAgentRunButton.disabled = false;
1328
+ executeAgentRunButton.textContent = "Run";
1329
+ agentRunOverlay.hidden = false;
1330
+ if (selected.config.requiresUserInput) {
1331
+ window.setTimeout(() => agentRunInput.focus(), 0);
1332
+ } else {
1333
+ window.setTimeout(() => executeAgentRunButton.focus(), 0);
1334
+ }
1335
+ }
1336
+
1337
+ function closeAgentRunDialog() {
1338
+ agentRunOverlay.hidden = true;
1339
+ agentRunTargetId = null;
1340
+ }
1341
+
1342
+ async function executeAgentRunFromDialog() {
1343
+ if (agentRunInput.required && !agentRunInput.value.trim()) {
1344
+ agentRunInput.reportValidity();
1345
+ return;
1346
+ }
1347
+
1348
+ const selected = agentRunTargetId ? entityById(agentRunTargetId) : null;
1349
+ if (!selected || selected.kind !== "agent") {
1350
+ showAgentRunResult("error", "The selected agent is no longer available.");
1351
+ return;
1352
+ }
1353
+
1354
+ const llm = entityById(selected.parentId ?? "");
1355
+ if (!llm || llm.kind !== "llm") {
1356
+ showAgentRunResult("error", "No parent LLM found for this agent.");
1357
+ return;
1358
+ }
1359
+
1360
+ executeAgentRunButton.disabled = true;
1361
+ executeAgentRunButton.textContent = "Running…";
1362
+ showAgentRunResult("loading", "Running agent…");
1363
+
1364
+ const mcpEntity = state.entities.find((e) => e.kind === "mcp");
1365
+
1366
+ const result = await runAgentPrompt({
1367
+ endpoint: llm.config.endpoint,
1368
+ token: llm.config.token,
1369
+ model: llm.config.model,
1370
+ systemPrompt: selected.config.systemPrompt,
1371
+ userInput: agentRunInput.value.trim() || undefined,
1372
+ timeout: llm.config.timeout,
1373
+ mcpEndpoint: mcpEntity?.config.endpoint || undefined,
1374
+ expectedOutputMarker: selected.config.expectedOutputMarker || undefined,
1375
+ });
1376
+
1377
+ executeAgentRunButton.disabled = false;
1378
+ executeAgentRunButton.textContent = "Run";
1379
+
1380
+ if (result.ok && result.content) {
1381
+ showAgentRunResult("success", result.content);
1382
+ showSaveToast("Agent responded.", "success");
1383
+ } else {
1384
+ showAgentRunResult("error", result.error ?? "Agent failed.");
1385
+ showSaveToast(result.error ?? "Agent failed.", "error");
1386
+ }
1387
+ }
1388
+
1389
+ function showAgentRunResult(
1390
+ state: "loading" | "success" | "error",
1391
+ message: string,
1392
+ ) {
1393
+ agentRunResult.hidden = false;
1394
+ agentRunResult.dataset.state = state;
1395
+ agentRunResult.textContent = message;
1396
+ }
1397
+
1398
+ async function loadModelsForSelect() {
1399
+ const selected = selectedEntity();
1400
+ if (!selected || selected.kind !== "llm") return;
1401
+
1402
+ syncSelectedFromForm();
1403
+ const endpoint = selected.config.endpoint;
1404
+ if (!endpoint) {
1405
+ showSaveToast("Set an endpoint first.");
1406
+ return;
1407
+ }
1408
+
1409
+ loadModelsButton.classList.add("loading");
1410
+ loadModelsButton.disabled = true;
1411
+ fields.model.disabled = true;
1412
+
1413
+ const result = await listLlmModels({
1414
+ endpoint,
1415
+ token: selected.config.token,
1416
+ });
1417
+
1418
+ loadModelsButton.classList.remove("loading");
1419
+ loadModelsButton.disabled = false;
1420
+ fields.model.disabled = false;
1421
+
1422
+ if (!result.ok || !result.models?.length) {
1423
+ showSaveToast(result.error ?? "No models found.");
1424
+ return;
1425
+ }
1426
+
1427
+ const previousValue = fields.model.value;
1428
+ fields.model.innerHTML = "";
1429
+ for (const id of result.models) {
1430
+ const opt = document.createElement("option");
1431
+ opt.value = id;
1432
+ opt.textContent = id;
1433
+ fields.model.appendChild(opt);
1434
+ }
1435
+ fields.model.value = result.models.includes(previousValue)
1436
+ ? previousValue
1437
+ : result.models[0];
1438
+ selected.config.model = fields.model.value;
1439
+ testNodeButton.disabled = !fields.model.value;
1440
+ showSaveToast(
1441
+ `${result.models.length} model${result.models.length === 1 ? "" : "s"} loaded.`,
1442
+ );
1443
+ scheduleRender();
1444
+ }
1445
+
1446
+ async function testSelectedLlmConnection() {
1447
+ const selected = selectedEntity();
1448
+ if (!selected || selected.kind !== "llm") {
1449
+ return;
1450
+ }
1451
+
1452
+ syncSelectedFromForm();
1453
+ testNodeButton.disabled = true;
1454
+ showLoadingToast(`Testing ${selected.config.model}…`);
1455
+ scheduleRender();
1456
+
1457
+ const result = await testLlmConnection({
1458
+ endpoint: selected.config.endpoint,
1459
+ token: selected.config.token,
1460
+ model: selected.config.model,
1461
+ timeout: selected.config.timeout,
1462
+ });
1463
+
1464
+ testNodeButton.disabled = false;
1465
+ if (result.ok) {
1466
+ showSaveToast(result.detail ?? "Connection OK", "success");
1467
+ } else {
1468
+ showSaveToast(
1469
+ result.error || `Connection failed (${result.status || "no status"})`,
1470
+ "error",
1471
+ );
1472
+ }
1473
+ scheduleRender();
992
1474
  }
993
1475
 
994
1476
  function labelForBadge(kind) {
@@ -1208,7 +1690,7 @@ function drawConfigSurface(width, height) {
1208
1690
  }
1209
1691
 
1210
1692
  const panelWidth = panelWidthForViewport(width);
1211
- const panelHeight = Math.min(PANEL_HEIGHT, height - 48);
1693
+ const panelHeight = Math.min(PANEL_HEIGHT, height - 118);
1212
1694
  const panelX = width - panelWidth - 24;
1213
1695
  const panelY = Math.max(24, (height - panelHeight) / 2);
1214
1696
 
@@ -1256,9 +1738,25 @@ function resetView(animate, resetZoom) {
1256
1738
 
1257
1739
  function syncCameraForPanelChange(hadSelection, hasSelection, animate) {
1258
1740
  if (hasSelection) {
1259
- const deltaX = selectedPanelAvoidanceDelta();
1260
- state.panelShiftX += Math.max(0, -deltaX);
1261
- translateCamera(deltaX, animate);
1741
+ if (!hadSelection) {
1742
+ // Panel was closed → center selected node in the left area
1743
+ const selected = selectedEntity();
1744
+ if (selected) {
1745
+ const panelReserve =
1746
+ panelWidthForViewport(canvas.clientWidth) + PANEL_GAP;
1747
+ const availableWidth = canvas.clientWidth - panelReserve;
1748
+ const targetX = availableWidth / 2 - selected.x * state.zoom;
1749
+ const appliedDelta = targetX - state.cameraX;
1750
+ // Store negative delta so deselect reverses the shift
1751
+ state.panelShiftX = -appliedDelta;
1752
+ startViewAnimation(targetX, state.cameraY, state.zoom);
1753
+ }
1754
+ } else {
1755
+ // Panel already open → avoid occlusion only if needed
1756
+ const deltaX = selectedPanelAvoidanceDelta();
1757
+ state.panelShiftX += Math.max(0, -deltaX);
1758
+ translateCamera(deltaX, animate);
1759
+ }
1262
1760
  return;
1263
1761
  }
1264
1762
 
@@ -1277,7 +1775,8 @@ function selectedPanelAvoidanceDelta() {
1277
1775
  return 0;
1278
1776
  }
1279
1777
 
1280
- const panelLeft = canvas.clientWidth - panelWidthForViewport(canvas.clientWidth) - 24;
1778
+ const panelLeft =
1779
+ canvas.clientWidth - panelWidthForViewport(canvas.clientWidth) - 24;
1281
1780
  const safeRight = panelLeft - PANEL_OCCLUSION_PADDING;
1282
1781
  const selectedBounds = screenBoundsForEntity(selected);
1283
1782
 
@@ -1488,8 +1987,7 @@ function isEntityHit(entity, point) {
1488
1987
  if (entity.kind === "system" || entity.kind === "llm") {
1489
1988
  const radius = entity.kind === "system" ? ROOT_RADIUS : PROVIDER_RADIUS;
1490
1989
  return (
1491
- Math.hypot(point.x - entity.x, point.y - entity.y) <=
1492
- radius + HIT_PADDING
1990
+ Math.hypot(point.x - entity.x, point.y - entity.y) <= radius + HIT_PADDING
1493
1991
  );
1494
1992
  }
1495
1993