PageSourceSearch

https://chat-engine.ai/assets/admin/controllers/graph_editor_controller-f4ceebf2.js

js chat-engine.ai collected 2026-10-05 13:48:47 UTC 29,551 bytes, 1,069 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Main coordinator controller for the graph editor
4// Manages communication between all page components
5export default class extends Controller {
6  static targets = ["graphContent", "iframePanel", "iframeContent"]
7
8  static values = { 
9    graphDataUrl: String,
10    presetId: String 
11  }
12
13  connect() {
14
15    // Initialize variables for tracking graph state
16    this.selectedNodeId = null
17    this.selectedNodeData = null
18
19    // Initialize array for storing iframe URL history
20    // Maximum length - 5 most recent URLs
21    this.iframeUrlHistory = []
22    this.maxHistoryLength = 5
23
24    // Initialize array for storing iframe URL history
25    this.iframeUrlHistory = []
26    this.maxHistoryLength = 5
27
28    this.initializeComponents()
29  }
30
31  // Adds URL to history
32  addUrlToHistory(url) {
33    if (!url || url === 'about:blank') return
34
35    // Add only if URL differs from the last one
36    if (this.iframeUrlHistory.length === 0 || this.iframeUrlHistory[0] !== url) {
37      // Add to the beginning of array
38      this.iframeUrlHistory.unshift(url)
39
40      // Limit history length
41      if (this.iframeUrlHistory.length > this.maxHistoryLength) {
42        this.iframeUrlHistory.pop()
43      }
44    }
45  }
46
47  // Initialize all components
48  async initializeComponents() {
49    await this.loadGraphContent()
50    this.setupGraphObserver()
51    this.bindIframeMessageListener()
52    this.bindGraphEventListeners()
53
54  }
55
56  // 1. GRAPH LOADING AND MONITORING
57
58  // Load graph content from server
59  async loadGraphContent() {
60    if (!this.graphDataUrlValue) {
61      console.warn("No graph data URL provided")
62      return
63    }
64
65    try {
66
67
68      const response = await fetch(this.graphDataUrlValue, {
69        method: 'GET',
70        headers: {
71          'Accept': 'text/html',
72          'X-Requested-With': 'XMLHttpRequest'
73        }
74      })
75
76      if (response.ok) {
77        const html = await response.text()
78        this.graphContentTarget.innerHTML = html
79
80        // Process links immediately after content loading
81        this.processGraphLinks()
82
83      } else {
84        console.error("Failed to load graph content:", response.status)
85        this.showGraphError("Failed to load graph content")
86      }
87    } catch (error) {
88      console.error("Error loading graph content:", error)
89      this.showGraphError("Error loading graph content")
90    }
91  }
92
93  // Setup DOM changes observer for graph panel
94  setupGraphObserver() {
95    if (!this.hasGraphContentTarget) return
96
97    // MutationObserver for tracking HTML changes
98    this.graphObserver = new MutationObserver((mutations) => {
99      let shouldProcessLinks = false
100
101      mutations.forEach((mutation) => {
102        if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
103          // Check if new elements with links were added
104          mutation.addedNodes.forEach((node) => {
105            if (node.nodeType === Node.ELEMENT_NODE) {
106              if (node.tagName === 'A' || node.querySelector('a')) {
107                shouldProcessLinks = true
108              }
109            }
110          })
111        }
112      })
113
114      if (shouldProcessLinks) {
115
116        this.processGraphLinks()
117      }
118    })
119
120    // Start observation
121    this.graphObserver.observe(this.graphContentTarget, {
122      childList: true,
123      subtree: true
124    })
125
126  }
127
128  // 2. LINK PROCESSING
129
130  // Process all links in graph
131  processGraphLinks() {
132    if (!this.hasGraphContentTarget) return
133
134    const links = this.graphContentTarget.querySelectorAll('a')
135
136
137    links.forEach((link) => {
138      this.processLink(link)
139    })
140  }
141
142  // Process single link
143  processLink(link) {
144    const href = link.getAttribute('href')
145    if (!href) return
146
147    // 1. Check if simple parameter is needed
148    if (this.needsSimpleParameter(href)) {
149      this.ensureSimpleParameter(link)
150    }
151
152    // 2. Check if clicks should be intercepted
153    if (this.shouldInterceptLink(href)) {
154      this.bindLinkClick(link)
155    }
156  }
157
158  // Check if link needs simple parameter
159  needsSimpleParameter(href) {
160    const adminRoutes = [
161      '/admin/prompts/',
162      '/admin/scenarios/',
163      '/admin/webhooks/',
164      '/admin/session_routers/',
165      '/admin/presets/'
166    ]
167
168    return adminRoutes.some(route => href.includes(route)) && !href.includes('simple=true')
169  }
170
171  // Add simple parameter to link
172  ensureSimpleParameter(link) {
173    const href = link.getAttribute('href')
174    const separator = href.includes('?') ? '&' : '?'
175    const newHref = `${href}${separator}simple=true`
176
177    link.setAttribute('href', newHref)
178
179  }
180
181  // Check if link clicks should be intercepted
182  shouldInterceptLink(href) {
183    const interceptRoutes = [
184      '/admin/prompts/',
185      '/admin/scenarios/',
186      '/admin/webhooks/',
187      '/admin/session_routers/',
188      '/admin/presets/'
189    ]
190
191    return interceptRoutes.some(route => href.includes(route))
192  }
193
194  // Bind click handler to link
195  bindLinkClick(link) {
196    // Remove old handler if exists
197    const existingHandler = link.dataset.graphEditorBound
198    if (existingHandler) return
199
200    // Add new handler
201    const clickHandler = (event) => {
202      event.preventDefault()
203      const url = link.getAttribute('href')
204
205      this.openInIframe(url)
206    }
207
208    link.addEventListener('click', clickHandler)
209    link.dataset.graphEditorBound = 'true'
210  }
211
212  // 3. IFRAME PANEL MANAGEMENT
213
214  // Open URL in iframe panel
215  openInIframe(url) {
216    if (!this.hasIframePanelTarget || !this.hasIframeContentTarget) {
217      console.warn("Iframe panel targets not found")
218      return
219    }
220
221
222    // Show panel
223    this.showIframePanel()
224
225    // Show loading
226    this.showIframeLoading()
227
228    // Save current URL for change tracking
229    this.currentIframeUrl = url
230
231    // Load URL
232    this.iframeContentTarget.src = url
233
234    // Bind event listeners after iframe loading
235    this.setupIframeEventListeners()
236  }
237
238  // Show iframe panel with animation
239  showIframePanel() {
240    this.iframePanelTarget.classList.remove('hidden')
241
242    // Show animation
243    setTimeout(() => {
244      this.iframePanelTarget.style.transform = 'translateX(0)'
245    }, 10)
246  }
247
248  // Hide iframe panel
249  hideIframePanel() {
250    // Clean up event listeners
251    this.cleanupIframeEventListeners()
252
253    this.iframePanelTarget.style.transform = 'translateX(100%)'
254
255    setTimeout(() => {
256      this.iframePanelTarget.classList.add('hidden')
257      this.iframePanelTarget.style.transform = 'translateX(0)'
258
259      // Clear iframe
260      if (this.hasIframeContentTarget) {
261        this.iframeContentTarget.src = 'about:blank'
262      }
263
264      // Reset tracked URLs
265      this.currentIframeUrl = null
266      this.previousIframeUrl = null
267
268      // Remove loading indicator
269      this.hideIframeLoading()
270    }, 300)
271  }
272
273  // Show loading indicator in iframe
274  showIframeLoading() {
275    // Remove existing indicator
276    this.hideIframeLoading()
277
278    const loadingOverlay = document.createElement('div')
279    loadingOverlay.className = 'iframe-loading-overlay absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center z-10'
280    loadingOverlay.innerHTML = `
281      <div class="text-center">
282        <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
283        <span class="text-gray-600">Loading...</span>
284      </div>
285    `
286
287    const iframeContent = this.iframePanelTarget.querySelector('.iframe-content')
288    if (iframeContent) {
289      iframeContent.style.position = 'relative'
290      iframeContent.appendChild(loadingOverlay)
291
292      // Remove on iframe load
293      if (this.hasIframeContentTarget) {
294        const hideLoading = () => this.hideIframeLoading()
295        this.iframeContentTarget.addEventListener('load', hideLoading, { once: true })
296
297        // Fallback timer
298        setTimeout(hideLoading, 10000)
299      }
300    }
301  }
302
303  // Setup event listeners for iframe
304  setupIframeEventListeners() {
305    if (!this.hasIframeContentTarget) return
306
307    // Clear old handlers
308    this.cleanupIframeEventListeners()
309
310    // Iframe load handler
311    this.iframeLoadHandler = () => {
312      this.onIframeLoad()
313    }
314
315    this.iframeContentTarget.addEventListener('load', this.iframeLoadHandler)
316
317  }
318
319  // Handle iframe load
320  onIframeLoad() {
321    try {
322      const iframe = this.iframeContentTarget
323      const iframeWindow = iframe.contentWindow
324      const iframeDocument = iframe.contentDocument
325
326      if (!iframeWindow || !iframeDocument) {
327        console.warn("Cannot access iframe content")
328        return
329      }
330
331      const currentUrl = iframeDocument.location.href
332
333      // Skip about:blank
334      if (currentUrl === 'about:blank') {
335        return
336      }
337
338
339      // Add URL to history
340      this.addUrlToHistory(currentUrl)
341
342      // Collect navigation URL history
343      this.addUrlToHistory(currentUrl)
344
345      // Check URL change
346      if (this.currentIframeUrl && this.currentIframeUrl !== currentUrl) {
347
348        this.handleIframeUrlChange(this.currentIframeUrl, currentUrl)
349      }
350
351      // Update current URL
352      this.currentIframeUrl = currentUrl
353
354      // Bind navigation handlers inside iframe
355      this.bindIframeNavigationEvents(iframeWindow)
356
357      // Initialize history observer in iframe if possible
358      this.setupIframeHistoryObserver(iframeWindow)
359
360    } catch (error) {
361      console.warn("Error handling iframe load:", error)
362    }
363  }
364
365  // Setup history observer for iframe
366  setupIframeHistoryObserver(iframeWindow) {
367    try {
368      // Get original history methods for interception
369      const originalPushState = iframeWindow.history.pushState
370      const originalReplaceState = iframeWindow.history.replaceState
371      const self = this
372
373      // Intercept pushState
374      iframeWindow.history.pushState = function() {
375        originalPushState.apply(this, arguments)
376        // Add current URL to history after change
377        const currentUrl = iframeWindow.location.href
378        self.addUrlToHistory(currentUrl)
379
380      }
381
382      // Intercept replaceState
383      iframeWindow.history.replaceState = function() {
384        originalReplaceState.apply(this, arguments)
385        // Add current URL to history after change
386        const currentUrl = iframeWindow.location.href
387        self.addUrlToHistory(currentUrl)
388
389      }
390
391    } catch (error) {
392      console.warn("Unable to setup iframe history observer:", error)
393    }
394  }
395
396  // Bind navigation handlers inside iframe
397  bindIframeNavigationEvents(iframeWindow) {
398    try {
399      // Popstate handler (browser navigation)
400      this.iframePopstateHandler = (event) => {
401
402        this.handleIframeNavigation()
403      }
404
405      // Beforeunload handler (before navigation)
406      this.iframeBeforeUnloadHandler = (event) => {
407
408        // Save current URL as previous
409        this.previousIframeUrl = this.currentIframeUrl
410      }
411
412      // Intercept clicks on links inside iframe
413      this.iframeClickHandler = (event) => {
414        const target = event.target.closest('a')
415        if (target && target.href) {
416          // Small delay for navigation processing
417          setTimeout(() => {
418            this.handleIframeNavigation()
419          }, 100)
420        }
421      }
422
423      // Bind handlers
424      iframeWindow.addEventListener('popstate', this.iframePopstateHandler)
425      iframeWindow.addEventListener('beforeunload', this.iframeBeforeUnloadHandler)
426      iframeWindow.document.addEventListener('click', this.iframeClickHandler, true)
427
428    } catch (error) {
429      console.warn("Error binding iframe navigation events:", error)
430    }
431  }
432
433  // Handle navigation inside iframe
434  handleIframeNavigation() {
435    try {
436      const iframe = this.iframeContentTarget
437      const newUrl = iframe.contentDocument.location.href
438
439      if (newUrl !== this.currentIframeUrl && newUrl !== 'about:blank') {
440
441
442        // Add URL to history
443        this.addUrlToHistory(newUrl)
444
445        // Handle URL change
446        this.handleIframeUrlChange(this.currentIframeUrl, newUrl)
447
448        // Update current URL
449        this.currentIframeUrl = newUrl
450      }
451    } catch (error) {
452      console.warn("Error handling iframe navigation:", error)
453    }
454  }
455
456  // Cleanup iframe event listeners
457  cleanupIframeEventListeners() {
458    if (this.iframeLoadHandler) {
459      this.iframeContentTarget?.removeEventListener('load', this.iframeLoadHandler)
460      this.iframeLoadHandler = null
461    }
462
463    // Clean up navigation handlers inside iframe
464    if (this.hasIframeContentTarget) {
465      try {
466        const iframeWindow = this.iframeContentTarget.contentWindow
467        if (iframeWindow) {
468          if (this.iframePopstateHandler) {
469            iframeWindow.removeEventListener('popstate', this.iframePopstateHandler)
470            this.iframePopstateHandler = null
471          }
472          if (this.iframeBeforeUnloadHandler) {
473            iframeWindow.removeEventListener('beforeunload', this.iframeBeforeUnloadHandler)
474            this.iframeBeforeUnloadHandler = null
475          }
476          if (this.iframeClickHandler) {
477            iframeWindow.document?.removeEventListener('click', this.iframeClickHandler, true)
478            this.iframeClickHandler = null
479          }
480        }
481      } catch (error) {
482        // Ignore errors during cleanup - iframe may be inaccessible
483      }
484    }
485
486  }
487
488  // Handle URL change in iframe
489  handleIframeUrlChange(oldUrl, newUrl) {
490
491
492    // 1. Check if graph needs update (transition from /edit to something else)
493    if (this.shouldUpdateGraphAfterUrlChange(oldUrl, newUrl)) {
494      this.updateGraphAfterIframeNavigation(oldUrl, newUrl)
495    }
496
497    // 2. Check and add simple parameter if needed
498    if (this.needsSimpleParameterAddition(newUrl)) {
499      this.addSimpleParameterToIframe(newUrl)
500      return // After reload, method will be called again
501    }
502  }
503
504  // Check if simple parameter needs to be added
505  needsSimpleParameterAddition(url) {
506    // Check admin routes and absence of simple parameter
507    const adminRoutes = [
508      '/admin/prompts/',
509      '/admin/scenarios/',
510      '/admin/webhooks/',
511      '/admin/session_routers/',
512      '/admin/presets/'
513    ]
514
515    const isAdminRoute = adminRoutes.some(route => url.includes(route))
516    const hasSimpleParam = url.includes('simple=true')
517
518    return isAdminRoute && !hasSimpleParam
519  }
520
521  // Add simple parameter and reload iframe
522  addSimpleParameterToIframe(url) {
523    const newUrl = this.ensureSimpleParameterForUrl(url)
524
525
526    // Update tracked URL
527    this.currentIframeUrl = newUrl
528
529    // Reload iframe with new URL
530    this.iframeContentTarget.src = newUrl
531
532  }
533
534  // Check frame URL history to determine if graph update is needed
535  shouldUpdateGraphBasedOnHistory() {
536    // Need at least 2 URLs in history for checking
537    if (this.iframeUrlHistory.length < 2) return false
538
539    // Get current and previous URLs
540    const currentUrl = this.iframeUrlHistory[0]
541
542    // Check last 3 URLs for "edit/new -> show" pattern
543    for (let i = 1; i < Math.min(3, this.iframeUrlHistory.length); i++) {
544      const prevUrl = this.iframeUrlHistory[i]
545
546      // Skip if any URL is empty
547      if (!prevUrl || !currentUrl) continue
548
549      const wasSavePage = this.isEditPage(prevUrl) || this.isNewPage(prevUrl)
550      const isViewPage = this.isViewPage(currentUrl) && !this.isEditPage(currentUrl) && !this.isNewPage(currentUrl)
551
552      if (wasSavePage && isViewPage) {
553        return true
554      }
555    }
556
557    return false
558  }
559
560  // Check if URL is a view page (show)
561  isViewPage(url) {
562    // View page URL usually contains only ID without action
563    // e.g.: /admin/scenarios/123 (without /edit, /new etc.)
564    const isAdminRoute = /\/admin\/(scenarios|session_routers|presets|prompts|webhooks)\/\d+(\?|$)/.test(url)
565    const hasNoAction = !url.includes('/edit') && !url.includes('/new')
566
567    return isAdminRoute && hasNoAction
568  }
569
570  // Keep old method for backward compatibility
571  shouldUpdateGraphAfterUrlChange(oldUrl, newUrl) {
572    if (!oldUrl || !newUrl) return false
573
574    // Check if this was transition from edit page
575    const wasEditPage = this.isEditPage(oldUrl)
576    const isEditPage = this.isEditPage(newUrl)
577
578    // Also check transitions from new (record creation)
579    const wasNewPage = this.isNewPage(oldUrl)
580
581    const shouldUpdate = (wasEditPage && !isEditPage) || (wasNewPage && !isEditPage)
582
583    return shouldUpdate
584  }
585
586  // Check if URL is edit page
587  isEditPage(url) {
588    return url.includes('/edit') || url.includes('/edit?')
589  }
590
591  // Check if URL is new page
592  isNewPage(url) {
593    return url.includes('/new') || url.includes('/new?')
594  }
595
596  // Update graph after iframe navigation
597  async updateGraphAfterIframeNavigation(oldUrl, newUrl) {
598
599
600    try {
601      // Show loading indicator on graph
602      this.showGraphLoadingIndicator()
603
604      // 1. Reload graph HTML content
605      await this.loadGraphContent()
606
607      // 2. Force refresh graph data through graph-with-panel controller
608      await this.forceRefreshGraphVisualization()
609
610      this.hideGraphLoadingIndicator()
611      this.showNotification("Visualisation updated", "success")
612
613    } catch (error) {
614      this.hideGraphLoadingIndicator()
615      console.error("Error updating graph after iframe navigation:", error)
616      this.showNotification("Update error", "error")
617    }
618  }
619
620  // Force refresh graph visualization
621  async forceRefreshGraphVisualization() {
622    // Find graph-with-panel controller
623    const graphPanel = this.element.querySelector('[data-controller*="graph-with-panel"]')
624
625    if (graphPanel) {
626      // Get controller instance
627      const graphController = this.application.getControllerForElementAndIdentifier(
628        graphPanel, 
629        'graph-with-panel'
630      )
631
632      if (graphController && typeof graphController.forceRefreshGraph === 'function') {
633
634        await graphController.forceRefreshGraph()
635      } else if (graphController && typeof graphController.refreshGraphData === 'function') {
636
637        await graphController.refreshGraphData()
638      } else {
639        console.warn("Graph controller not found or refresh method not available, falling back to event dispatch")
640        // Fallback - dispatch event for force refresh
641        this.dispatchGraphRefreshEvent()
642      }
643    } else {
644      console.warn("Graph panel element not found")
645    }
646  }
647
648  // Dispatch event for graph refresh
649  dispatchGraphRefreshEvent() {
650    const refreshEvent = new CustomEvent('graph:forceRefresh', {
651      bubbles: true,
652      detail: {
653        reason: 'iframe-navigation',
654        timestamp: Date.now()
655      }
656    })
657
658    this.element.dispatchEvent(refreshEvent)
659
660  }
661
662  // Show loading indicator on graph
663  showGraphLoadingIndicator() {
664    if (!this.hasGraphContentTarget) return
665
666    // Remove existing indicator
667    this.hideGraphLoadingIndicator()
668
669    const loadingOverlay = document.createElement('div')
670    loadingOverlay.className = 'graph-loading-overlay absolute inset-0 bg-white bg-opacity-90 flex items-center justify-center z-40'
671    loadingOverlay.innerHTML = `
672      <div class="text-center">
673        <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
674        <span class="text-gray-700 font-medium">Updating graph...</span>
675      </div>
676    `
677
678    this.graphContentTarget.style.position = 'relative'
679    this.graphContentTarget.appendChild(loadingOverlay)
680  }
681
682  // Hide graph loading indicator
683  hideGraphLoadingIndicator() {
684    const overlay = this.graphContentTarget?.querySelector('.graph-loading-overlay')
685    if (overlay) {
686      overlay.remove()
687    }
688  }
689
690  /**
691   * Force refresh the graph
692   */
693  forceRefreshGraph() {
694    console.log('GraphEditor: Force refresh graph');
695    this.loadGraphContent();
696    this.setupEventListeners();
697  }
698
699  /**
700   * Setup event listeners
701   */
702  setupEventListeners() {
703    // Handler for data requests from panel controller
704    this.element.addEventListener('request-graph-data', (event) => {
705      console.log('GraphEditor: Received graph data request');
706      const graphData = this.getCurrentGraphData();
707      if (graphData && event.detail.requester) {
708        console.log('GraphEditor: Passing data to requesting controller');
709        event.detail.requester.updateGraphData(graphData);
710      }
711    });
712  }
713
714
715  // Hide iframe loading indicator  
716  hideIframeLoading() {
717    const overlay = this.iframePanelTarget?.querySelector('.iframe-loading-overlay')
718    if (overlay) {
719      overlay.remove()
720    }
721  }
722
723  // 4. IFRAME MESSAGE HANDLING
724
725  // Message listener from iframe
726  bindIframeMessageListener() {
727    this.messageHandler = (event) => {
728      if (event.data && event.data.type === 'form-saved-successfully') {
729        this.handleFormSaved(event.data)
730      }
731    }
732
733    window.addEventListener('message', this.messageHandler)
734
735  }
736
737  // Bind event listeners from graph component
738  bindGraphEventListeners() {
739    // Graph node click handler
740    this.graphNodeClickHandler = (event) => {
741
742      this.handleGraphNodeClick(event.detail)
743    }
744
745    // Graph panel hidden handler
746    this.graphPanelHiddenHandler = (event) => {
747
748      this.handleGraphPanelHidden(event.detail)
749    }
750
751    // Bind handlers to controller element
752    this.element.addEventListener('graph:nodeSelected', this.graphNodeClickHandler)
753    this.element.addEventListener('graph:panelHidden', this.graphPanelHiddenHandler)
754
755  }
756
757  // Handle graph node click
758  handleGraphNodeClick(eventDetail) {
759    const { nodeId, nodeData, presetId } = eventDetail
760
761
762    // Save selected node information
763    this.selectedNodeId = nodeId
764    this.selectedNodeData = nodeData
765
766    // Handle links in opened panel with delay
767    setTimeout(() => {
768      this.handleInfoPanelLinks()
769    }, 100) // Small delay for full panel rendering
770  }
771
772  // Handle graph panel hidden
773  handleGraphPanelHidden(eventDetail) {
774    const { lastSelectedNode } = eventDetail
775
776
777    // Reset selected node information
778    this.selectedNodeId = null
779    this.selectedNodeData = null
780
781    // Reset panel link handling
782    this.clearInfoPanelLinkHandlers()
783
784  }
785
786  // Clear info panel link handlers
787  clearInfoPanelLinkHandlers() {
788    const panel = this.findInfoPanel()
789
790    if (!panel) return
791
792    // Find all processed links and reset their state
793    const processedLinks = panel.querySelectorAll('a[data-graph-editor-processed="true"]')
794
795    processedLinks.forEach((link) => {
796      // Remove processing marker (event handlers will remain, but on next
797      // panel opening links will be reprocessed with current data)
798      delete link.dataset.graphEditorProcessed
799    })
800
801  }
802
803  // Handle successful form save in iframe
804  async handleFormSaved(eventData) {
805
806
807
808    try {
809      // Show loading indicator on graph
810      this.showGraphLoadingIndicator()
811
812      // Mark in history that form was saved
813      // This allows to know exactly that graph needs update
814      if (this.iframeUrlHistory.length > 0) {
815        // Add marker that form was saved
816        this.addUrlToHistory("form-saved:" + this.iframeUrlHistory[0])
817
818      }
819
820      // 1. Reload graph HTML content
821      await this.loadGraphContent()
822
823      // 2. Force refresh graph data through graph-with-panel controller
824      await this.forceRefreshGraphVisualization()
825
826      this.hideGraphLoadingIndicator()
827      this.showNotification("Visualisation updated", "success")
828
829    } catch (error) {
830      this.hideGraphLoadingIndicator()
831      console.error("Error updating graph after form save:", error)
832      this.showNotification("Error loading data", "error")
833    }
834
835    // Optionally we can hide iframe panel
836    // this.hideIframePanel()
837  }
838
839  // 5. UTILITIES AND UI
840
841  // Show notifications
842  showNotification(message, type = 'info') {
843    const notification = document.createElement('div')
844
845    const typeClasses = {
846      'success': 'bg-green-500',
847      'error': 'bg-red-500', 
848      'info': 'bg-blue-500',
849      'warning': 'bg-yellow-500'
850    }
851
852    notification.className = `fixed top-4 right-4 ${typeClasses[type]} text-white p-4 rounded shadow-lg z-50 transform transition-transform duration-300`
853    notification.textContent = message
854
855    document.body.appendChild(notification)
856
857    // Auto-remove after 3 seconds
858    setTimeout(() => {
859      notification.style.transform = 'translateX(100%)'
860      setTimeout(() => notification.remove(), 300)
861    }, 3000)
862  }
863
864  // Show graph loading error
865  showGraphError(message) {
866    if (this.hasGraphContentTarget) {
867      this.graphContentTarget.innerHTML = `
868        <div class="flex items-center justify-center h-full">
869          <div class="text-center">
870            <svg class="w-16 h-16 text-red-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
871              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
872            </svg>
873            <p class="text-red-600 mb-4">${message}</p>
874            <button class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
875                    data-action="click->
875graph-editor#refreshGraph">
876              Try again
877            </button>
878          </div>
879        </div>
880      `
881    }
882  }
883
884  // 6. PUBLIC ACTIONS (for use from HTML)
885
886  // Force graph refresh
887  refreshGraph() {
888
889    this.loadGraphContent()
890  }
891
892  // Close iframe panel
893  closeIframePanel() {
894    this.hideIframePanel()
895  }
896
897  // Force link reprocessing (for debugging)
898  reprocessLinks() {
899
900    this.processGraphLinks()
901  }
902
903  // Get selected node information (public API)
904  getSelectedNode() {
905    return {
906      nodeId: this.selectedNodeId,
907      nodeData: this.selectedNodeData,
908      hasSelection: this.selectedNodeId !== null
909    }
910  }
911
912  // Check if node is selected (public API)
913  hasSelectedNode() {
914    return this.selectedNodeId !== null
915  }
916
917  // Clear node selection (public API) 
918  clearNodeSelection() {
919    this.selectedNodeId = null
920    this.selectedNodeData = null
921
922  }
923
924  // Find graph info panel
925  findInfoPanel() {
926    // First search in controller element
927    let panel = this.element.querySelector('[data-graph-with-panel-target="infoPanel"]')
928
929    if (!panel) {
930      panel = this.element.querySelector('#graph-info-panel')
931    }
932
933    if (!panel) {
934      // If not found in controller element, search globally
935      panel = document.querySelector('[data-graph-with-panel-target="infoPanel"]')
936    }
937
938    if (!panel) {
939      panel = document.getElementById('graph-info-panel')
940    }
941
942    return panel
943  }
944
945  // Handle links in info panel
946  handleInfoPanelLinks() {
947    const panel = this.findInfoPanel()
948
949    if (!panel) {
950      console.warn("Graph editor: Info panel not found for link processing")
951      return
952    }
953
954    // Check if panel is visible
955    const isVisible = panel.style.display !== 'none' && 
956                     !panel.classList.contains('info-panel-hide') &&
957                     (panel.classList.contains('info-panel-show') || 
958                      getComputedStyle(panel).opacity !== '0')
959
960    if (!isVisible) {
961
962      return
963    }
964
965
966    // Find all links in panel
967    const links = panel.querySelectorAll('a[href]')
968
969
970    links.forEach((link) => {
971      this.processInfoPanelLink(link)
972    })
973  }
974
975  // Process single link in info panel
976  processInfoPanelLink(link) {
977    const href = link.getAttribute('href')
978    if (!href) return
979
980    // Check if link is already processed
981    if (link.dataset.graphEditorProcessed === 'true') {
982      return
983    }
984
985
986    // Check if link belongs to admin routes
987    const adminRoutes = [
988      '/admin/scenarios/',
989      '/admin/session_routers/',
990      '/admin/prompts/',
991      '/admin/webhooks/',
992      '/admin/presets/'
993    ]
994
995    const shouldIntercept = adminRoutes.some(route => href.includes(route))
996
997    if (shouldIntercept) {
998      // Remove default behavior and add our handler
999      link.addEventListener('click', (event) => {
1000        event.preventDefault()
1001
1002
1003        // Add simple parameter if needed
1004        const finalUrl = this.ensureSimpleParameterForUrl(href)
1005
1006        // Open in iframe
1007        this.openInIframe(finalUrl)
1008
1009      })
1010
1011      // Mark link as processed
1012      link.dataset.graphEditorProcessed = 'true'
1013
1014      // Add visual indicator that link will open in panel
1015      link.title = `${link.title || link.textContent} (will open in editing panel)`
1016
1017    } else {
1018
1019    }
1020  }
1021
1022  // Add simple parameter to URL if needed
1023  ensureSimpleParameterForUrl(url) {
1024    if (url.includes('simple=true')) {
1025      return url
1026    }
1027
1028    const separator = url.includes('?') ? '&' : '?'
1029    return `${url}${separator}simple=true`
1030  }
1031
1032  // 7. CLEANUP
1033
1034  disconnect() {
1035    // Cleanup iframe event listeners
1036    this.cleanupIframeEventListeners()
1037
1038    // Cleanup observer
1039    if (this.graphObserver) {
1040      this.graphObserver.disconnect()
1041    }
1042
1043    // Cleanup message listener
1044    if (this.messageHandler) {
1045      window.removeEventListener('message', this.messageHandler)
1046    }
1047
1048    // Cleanup graph event handlers
1049    if (this.graphNodeClickHandler) {
1050      this.element.removeEventListener('graph:nodeSelected', this.graphNodeClickHandler)
1051    }
1052    if (this.graphPanelHiddenHandler) {
1053      this.element.removeEventListener('graph:panelHidden', this.graphPanelHiddenHandler)
1054    }
1055
1056    // Cleanup loading indicators
1057    this.hideIframeLoading()
1058    this.hideGraphLoadingIndicator()
1059
1060    // Cleanup state variables
1061    this.selectedNodeId = null
1062    this.selectedNodeData = null
1063    this.currentIframeUrl = null
1064    this.previousIframeUrl = null
1065      this.iframeUrlHistory = []
1066    this.iframeUrlHistory = []
1067
1068  }
1069}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.