Add files via upload

This commit is contained in:
公明
2025-11-17 02:43:36 +08:00
committed by GitHub
parent 71b6c8aa2d
commit 9f862ce721
10 changed files with 2646 additions and 12 deletions
+2
View File
@@ -16,6 +16,7 @@
![Preview](./img/外部MCP接入.png)
## Changelog
- 2025.11.15 Added attack chain visualization feature: automatically build attack chains from conversations using AI analysis, visualize tool execution flows, vulnerability discovery paths, and relationships between nodes, support interactive graph exploration with risk scoring
- 2025.11.15 Added large result pagination feature: when tool execution results exceed the threshold (default 200KB), automatically save to file and return execution ID, support paginated queries, keyword search, conditional filtering, and regex matching through query_execution_result tool, effectively solving the problem of overly long single responses and improving large file processing capabilities
- 2025.11.15 Added external MCP integration feature: support for integrating external MCP servers to extend tool capabilities, supports both stdio and HTTP transport modes, tool-level enable/disable control, complete configuration guide and management APIs
- 2025.11.14 Performance optimizations: optimized tool lookup from O(n) to O(1) using index map, added automatic cleanup mechanism for execution records to prevent memory leaks, and added pagination support for database queries
@@ -36,6 +37,7 @@
- 📊 **Conversation History Management** - Complete conversation history records, supports viewing, deletion, and management
- ⚙️ **Visual Configuration Management** - Web interface for system settings, supports real-time loading and saving configurations with required field validation
- 📄 **Large Result Pagination** - When tool execution results exceed the threshold, automatically save to file, support paginated queries, keyword search, conditional filtering, and regex matching, effectively solving the problem of overly long single responses, with examples for various tools (head, tail, grep, sed, etc.) for segmented reading
- 🔗 **Attack Chain Visualization** - Automatically build and visualize attack chains from conversations, showing tool execution flows, vulnerability discovery paths, and relationships between targets, tools, vulnerabilities, and discoveries, with AI-powered analysis and interactive graph exploration
### Tool Integration
- 🔌 **MCP Protocol Support** - Complete MCP protocol implementation, supports tool registration, invocation, and monitoring
+2
View File
@@ -15,6 +15,7 @@
![详情预览](./img/外部MCP接入.png)
## 更新日志
- 2025.11.17 新增攻击链可视化功能:基于AI分析自动从对话中构建攻击链,可视化展示工具执行流程、漏洞发现路径和节点间关联关系,支持交互式图谱探索和风险评分
- 2025.11.15 新增大结果分段读取功能:当工具执行结果超过阈值(默认200KB)时,自动保存到文件并返回执行ID,支持通过 query_execution_result 工具进行分页查询、关键词搜索、条件过滤和正则表达式匹配,有效解决单次返回过长的问题,提升大文件处理能力
- 2025.11.15 新增外部 MCP 接入功能:支持接入外部 MCP 服务器扩展工具能力,支持 stdio 和 HTTP 两种传输模式,支持工具级别的启用/禁用控制,提供完整的配置指南和管理接口
- 2025.11.14 性能优化:工具查找从 O(n) 优化为 O(1)(使用索引映射),添加执行记录自动清理机制防止内存泄漏,数据库查询支持分页加载
@@ -36,6 +37,7 @@
- 📊 **对话历史管理** - 完整的对话历史记录,支持查看、删除和管理
- ⚙️ **可视化配置管理** - Web界面配置系统设置,支持实时加载和保存配置,必填项验证
- 📄 **大结果分段读取** - 当工具执行结果超过阈值时自动保存,支持分页查询、关键词搜索、条件过滤和正则表达式匹配,有效解决单次返回过长问题,提供多种工具(head、tail、grep、sed等)的分段读取示例
- 🔗 **攻击链可视化** - 基于AI分析自动从对话中构建攻击链,可视化展示工具执行流程、漏洞发现路径以及目标、工具、漏洞、发现之间的关联关系,支持交互式图谱探索和风险评分
### 工具集成
- 🔌 **MCP协议支持** - 完整实现MCP协议,支持工具注册、调用、监控
+7
View File
@@ -130,6 +130,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger)
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, externalMCPMgr, log.Logger)
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
// 设置路由
setupRoutes(
@@ -140,6 +141,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
conversationHandler,
configHandler,
externalMCPHandler,
attackChainHandler,
mcpServer,
authManager,
)
@@ -198,6 +200,7 @@ func setupRoutes(
conversationHandler *handler.ConversationHandler,
configHandler *handler.ConfigHandler,
externalMCPHandler *handler.ExternalMCPHandler,
attackChainHandler *handler.AttackChainHandler,
mcpServer *mcp.Server,
authManager *security.AuthManager,
) {
@@ -251,6 +254,10 @@ func setupRoutes(
protected.POST("/external-mcp/:name/start", externalMCPHandler.StartExternalMCP)
protected.POST("/external-mcp/:name/stop", externalMCPHandler.StopExternalMCP)
// 攻击链可视化
protected.GET("/attack-chain/:conversationId", attackChainHandler.GetAttackChain)
protected.POST("/attack-chain/:conversationId/regenerate", attackChainHandler.RegenerateAttackChain)
// MCP端点
protected.POST("/mcp", func(c *gin.Context) {
mcpServer.HandleHTTP(c.Writer, c.Request)
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
package database
import (
"database/sql"
"encoding/json"
"fmt"
"go.uber.org/zap"
)
// AttackChainNode 攻击链节点
type AttackChainNode struct {
ID string `json:"id"`
Type string `json:"type"` // tool, vulnerability, target, exploit
Label string `json:"label"`
ToolExecutionID string `json:"tool_execution_id,omitempty"`
Metadata map[string]interface{} `json:"metadata"`
RiskScore int `json:"risk_score"`
}
// AttackChainEdge 攻击链边
type AttackChainEdge struct {
ID string `json:"id"`
Source string `json:"source"`
Target string `json:"target"`
Type string `json:"type"` // leads_to, exploits, enables, depends_on
Weight int `json:"weight"`
}
// SaveAttackChainNode 保存攻击链节点
func (db *DB) SaveAttackChainNode(conversationID, nodeID, nodeType, nodeName, toolExecutionID, metadata string, riskScore int) error {
var toolExecID sql.NullString
if toolExecutionID != "" {
toolExecID = sql.NullString{String: toolExecutionID, Valid: true}
}
var metadataJSON sql.NullString
if metadata != "" {
metadataJSON = sql.NullString{String: metadata, Valid: true}
}
query := `
INSERT OR REPLACE INTO attack_chain_nodes
(id, conversation_id, node_type, node_name, tool_execution_id, metadata, risk_score, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`
_, err := db.Exec(query, nodeID, conversationID, nodeType, nodeName, toolExecID, metadataJSON, riskScore)
if err != nil {
db.logger.Error("保存攻击链节点失败", zap.Error(err), zap.String("nodeId", nodeID))
return err
}
return nil
}
// SaveAttackChainEdge 保存攻击链边
func (db *DB) SaveAttackChainEdge(conversationID, edgeID, sourceNodeID, targetNodeID, edgeType string, weight int) error {
query := `
INSERT OR REPLACE INTO attack_chain_edges
(id, conversation_id, source_node_id, target_node_id, edge_type, weight, created_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`
_, err := db.Exec(query, edgeID, conversationID, sourceNodeID, targetNodeID, edgeType, weight)
if err != nil {
db.logger.Error("保存攻击链边失败", zap.Error(err), zap.String("edgeId", edgeID))
return err
}
return nil
}
// LoadAttackChainNodes 加载攻击链节点
func (db *DB) LoadAttackChainNodes(conversationID string) ([]AttackChainNode, error) {
query := `
SELECT id, node_type, node_name, tool_execution_id, metadata, risk_score
FROM attack_chain_nodes
WHERE conversation_id = ?
ORDER BY created_at ASC
`
rows, err := db.Query(query, conversationID)
if err != nil {
return nil, fmt.Errorf("查询攻击链节点失败: %w", err)
}
defer rows.Close()
var nodes []AttackChainNode
for rows.Next() {
var node AttackChainNode
var toolExecID sql.NullString
var metadataJSON sql.NullString
err := rows.Scan(&node.ID, &node.Type, &node.Label, &toolExecID, &metadataJSON, &node.RiskScore)
if err != nil {
db.logger.Warn("扫描攻击链节点失败", zap.Error(err))
continue
}
if toolExecID.Valid {
node.ToolExecutionID = toolExecID.String
}
if metadataJSON.Valid && metadataJSON.String != "" {
if err := json.Unmarshal([]byte(metadataJSON.String), &node.Metadata); err != nil {
db.logger.Warn("解析节点元数据失败", zap.Error(err))
node.Metadata = make(map[string]interface{})
}
} else {
node.Metadata = make(map[string]interface{})
}
nodes = append(nodes, node)
}
return nodes, nil
}
// LoadAttackChainEdges 加载攻击链边
func (db *DB) LoadAttackChainEdges(conversationID string) ([]AttackChainEdge, error) {
query := `
SELECT id, source_node_id, target_node_id, edge_type, weight
FROM attack_chain_edges
WHERE conversation_id = ?
ORDER BY created_at ASC
`
rows, err := db.Query(query, conversationID)
if err != nil {
return nil, fmt.Errorf("查询攻击链边失败: %w", err)
}
defer rows.Close()
var edges []AttackChainEdge
for rows.Next() {
var edge AttackChainEdge
err := rows.Scan(&edge.ID, &edge.Source, &edge.Target, &edge.Type, &edge.Weight)
if err != nil {
db.logger.Warn("扫描攻击链边失败", zap.Error(err))
continue
}
edges = append(edges, edge)
}
return edges, nil
}
// DeleteAttackChain 删除对话的攻击链数据
func (db *DB) DeleteAttackChain(conversationID string) error {
// 先删除边(因为有外键约束)
_, err := db.Exec("DELETE FROM attack_chain_edges WHERE conversation_id = ?", conversationID)
if err != nil {
db.logger.Warn("删除攻击链边失败", zap.Error(err))
}
// 再删除节点
_, err = db.Exec("DELETE FROM attack_chain_nodes WHERE conversation_id = ?", conversationID)
if err != nil {
db.logger.Error("删除攻击链节点失败", zap.Error(err), zap.String("conversationId", conversationID))
return err
}
return nil
}
+42
View File
@@ -101,6 +101,36 @@ func (db *DB) initTables() error {
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`
// 创建攻击链节点表
createAttackChainNodesTable := `
CREATE TABLE IF NOT EXISTS attack_chain_nodes (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
node_type TEXT NOT NULL,
node_name TEXT NOT NULL,
tool_execution_id TEXT,
metadata TEXT,
risk_score INTEGER DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (tool_execution_id) REFERENCES tool_executions(id) ON DELETE SET NULL
);`
// 创建攻击链边表
createAttackChainEdgesTable := `
CREATE TABLE IF NOT EXISTS attack_chain_edges (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
source_node_id TEXT NOT NULL,
target_node_id TEXT NOT NULL,
edge_type TEXT NOT NULL,
weight INTEGER DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (source_node_id) REFERENCES attack_chain_nodes(id) ON DELETE CASCADE,
FOREIGN KEY (target_node_id) REFERENCES attack_chain_nodes(id) ON DELETE CASCADE
);`
// 创建索引
createIndexes := `
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
@@ -110,6 +140,10 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_tool_executions_tool_name ON tool_executions(tool_name);
CREATE INDEX IF NOT EXISTS idx_tool_executions_start_time ON tool_executions(start_time);
CREATE INDEX IF NOT EXISTS idx_tool_executions_status ON tool_executions(status);
CREATE INDEX IF NOT EXISTS idx_chain_nodes_conversation ON attack_chain_nodes(conversation_id);
CREATE INDEX IF NOT EXISTS idx_chain_edges_conversation ON attack_chain_edges(conversation_id);
CREATE INDEX IF NOT EXISTS idx_chain_edges_source ON attack_chain_edges(source_node_id);
CREATE INDEX IF NOT EXISTS idx_chain_edges_target ON attack_chain_edges(target_node_id);
`
if _, err := db.Exec(createConversationsTable); err != nil {
@@ -132,6 +166,14 @@ func (db *DB) initTables() error {
return fmt.Errorf("创建tool_stats表失败: %w", err)
}
if _, err := db.Exec(createAttackChainNodesTable); err != nil {
return fmt.Errorf("创建attack_chain_nodes表失败: %w", err)
}
if _, err := db.Exec(createAttackChainEdgesTable); err != nil {
return fmt.Errorf("创建attack_chain_edges表失败: %w", err)
}
if _, err := db.Exec(createIndexes); err != nil {
return fmt.Errorf("创建索引失败: %w", err)
}
+152
View File
@@ -0,0 +1,152 @@
package handler
import (
"context"
"net/http"
"sync"
"time"
"cyberstrike-ai/internal/attackchain"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AttackChainHandler 攻击链处理器
type AttackChainHandler struct {
db *database.DB
logger *zap.Logger
openAIConfig *config.OpenAIConfig
// 用于防止同一对话的并发生成
generatingLocks sync.Map // map[string]*sync.Mutex
}
// NewAttackChainHandler 创建新的攻击链处理器
func NewAttackChainHandler(db *database.DB, openAIConfig *config.OpenAIConfig, logger *zap.Logger) *AttackChainHandler {
return &AttackChainHandler{
db: db,
logger: logger,
openAIConfig: openAIConfig,
}
}
// GetAttackChain 获取攻击链(按需生成)
// GET /api/attack-chain/:conversationId
func (h *AttackChainHandler) GetAttackChain(c *gin.Context) {
conversationID := c.Param("conversationId")
if conversationID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "conversationId is required"})
return
}
// 检查对话是否存在
_, err := h.db.GetConversation(conversationID)
if err != nil {
h.logger.Warn("对话不存在", zap.String("conversationId", conversationID), zap.Error(err))
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
return
}
// 先尝试从数据库加载(如果已生成过)
builder := attackchain.NewBuilder(h.db, h.openAIConfig, h.logger)
chain, err := builder.LoadChainFromDatabase(conversationID)
if err == nil && len(chain.Nodes) > 0 {
// 如果已存在,直接返回
h.logger.Info("返回已存在的攻击链", zap.String("conversationId", conversationID))
c.JSON(http.StatusOK, chain)
return
}
// 如果不存在,则生成新的攻击链(按需生成)
// 使用锁机制防止同一对话的并发生成
lockInterface, _ := h.generatingLocks.LoadOrStore(conversationID, &sync.Mutex{})
lock := lockInterface.(*sync.Mutex)
// 尝试获取锁,如果正在生成则返回错误
acquired := lock.TryLock()
if !acquired {
h.logger.Info("攻击链正在生成中,请稍后再试", zap.String("conversationId", conversationID))
c.JSON(http.StatusConflict, gin.H{"error": "攻击链正在生成中,请稍后再试"})
return
}
defer lock.Unlock()
// 再次检查是否已生成(可能在等待锁的过程中已经生成完成)
chain, err = builder.LoadChainFromDatabase(conversationID)
if err == nil && len(chain.Nodes) > 0 {
h.logger.Info("返回已存在的攻击链(在锁等待期间已生成)", zap.String("conversationId", conversationID))
c.JSON(http.StatusOK, chain)
return
}
h.logger.Info("开始生成攻击链", zap.String("conversationId", conversationID))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
chain, err = builder.BuildChainFromConversation(ctx, conversationID)
if err != nil {
h.logger.Error("生成攻击链失败", zap.String("conversationId", conversationID), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成攻击链失败: " + err.Error()})
return
}
// 生成完成后,从锁映射中删除(可选,保留也可以用于防止短时间内重复生成)
// h.generatingLocks.Delete(conversationID)
c.JSON(http.StatusOK, chain)
}
// RegenerateAttackChain 重新生成攻击链
// POST /api/attack-chain/:conversationId/regenerate
func (h *AttackChainHandler) RegenerateAttackChain(c *gin.Context) {
conversationID := c.Param("conversationId")
if conversationID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "conversationId is required"})
return
}
// 检查对话是否存在
_, err := h.db.GetConversation(conversationID)
if err != nil {
h.logger.Warn("对话不存在", zap.String("conversationId", conversationID), zap.Error(err))
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
return
}
// 删除旧的攻击链
if err := h.db.DeleteAttackChain(conversationID); err != nil {
h.logger.Warn("删除旧攻击链失败", zap.Error(err))
}
// 使用锁机制防止并发生成
lockInterface, _ := h.generatingLocks.LoadOrStore(conversationID, &sync.Mutex{})
lock := lockInterface.(*sync.Mutex)
acquired := lock.TryLock()
if !acquired {
h.logger.Info("攻击链正在生成中,请稍后再试", zap.String("conversationId", conversationID))
c.JSON(http.StatusConflict, gin.H{"error": "攻击链正在生成中,请稍后再试"})
return
}
defer lock.Unlock()
// 生成新的攻击链
h.logger.Info("重新生成攻击链", zap.String("conversationId", conversationID))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
builder := attackchain.NewBuilder(h.db, h.openAIConfig, h.logger)
chain, err := builder.BuildChainFromConversation(ctx, conversationID)
if err != nil {
h.logger.Error("生成攻击链失败", zap.String("conversationId", conversationID), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成攻击链失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, chain)
}
+138
View File
@@ -2579,3 +2579,141 @@ header {
grid-template-columns: 1fr;
}
}
/* ==================== 攻击链可视化样式 ==================== */
.attack-chain-modal-content {
max-width: 95vw;
width: 95vw;
height: 90vh;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.attack-chain-body {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
padding: 0;
}
.attack-chain-controls {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
background: var(--bg-secondary);
}
.attack-chain-info {
font-size: 0.875rem;
color: var(--text-secondary);
}
.attack-chain-legend {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid var(--border-color);
}
.attack-chain-container {
flex: 1;
min-height: 0;
background: var(--bg-primary);
border: 1px solid var(--border-color);
position: relative;
}
.attack-chain-details {
padding: 16px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
max-height: 200px;
overflow-y: auto;
}
.attack-chain-details h3 {
margin: 0 0 12px 0;
font-size: 1rem;
color: var(--text-primary);
}
.node-detail-item {
margin-bottom: 8px;
font-size: 0.875rem;
}
.node-detail-item strong {
color: var(--text-primary);
margin-right: 8px;
}
.node-detail-item code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.8125rem;
}
.metadata-pre {
background: var(--bg-tertiary);
padding: 8px;
border-radius: 4px;
font-size: 0.8125rem;
overflow-x: auto;
margin-top: 4px;
}
.modal-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
font-size: 1rem;
}
.empty-message {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
font-size: 1rem;
}
.error-message {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--error-color);
font-size: 1rem;
padding: 20px;
}
+1040 -12
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -304,10 +304,73 @@
</div>
</div>
<!-- 攻击链可视化模态框 -->
<div id="attack-chain-modal" class="modal">
<div class="modal-content attack-chain-modal-content">
<div class="modal-header">
<h2>攻击链可视化</h2>
<div class="modal-header-actions">
<button class="btn-secondary" onclick="regenerateAttackChain()" title="重新生成攻击链(包含最新对话内容)" style="background: #007bff; color: white; border-color: #007bff; margin-right: 8px;">
🔄 重新生成
</button>
<button class="btn-secondary" onclick="exportAttackChain('png')" title="导出为PNG">
📥 PNG
</button>
<button class="btn-secondary" onclick="exportAttackChain('svg')" title="导出为SVG">
📥 SVG
</button>
<button class="btn-secondary" onclick="refreshAttackChain()" title="刷新当前攻击链(不重新生成)">
↻ 刷新
</button>
<span class="modal-close" onclick="closeAttackChainModal()">&times;</span>
</div>
</div>
<div class="modal-body attack-chain-body">
<div class="attack-chain-controls">
<div class="attack-chain-info">
<span id="attack-chain-stats">节点: 0 | 边: 0</span>
</div>
<div class="attack-chain-legend">
<div class="legend-item">
<span class="legend-color" style="background: #ff4444;"></span>
<span>高风险 (80-100)</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #ff8800;"></span>
<span>中高风险 (60-79)</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #ffbb00;"></span>
<span>中风险 (40-59)</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #88cc00;"></span>
<span>低风险 (0-39)</span>
</div>
</div>
</div>
<div id="attack-chain-container" class="attack-chain-container">
<div class="loading-spinner">加载中...</div>
</div>
<div id="attack-chain-details" class="attack-chain-details" style="display: none;">
<h3>节点详情</h3>
<div id="attack-chain-details-content"></div>
</div>
</div>
</div>
</div>
<!-- Marked.js for Markdown parsing -->
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<!-- DOMPurify for HTML sanitization to prevent XSS -->
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
<!-- Cytoscape.js for attack chain visualization -->
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.27.0/dist/cytoscape.min.js"></script>
<!-- dagre layout dependencies -->
<script src="https://cdn.jsdelivr.net/npm/graphlib@2.1.8/dist/graphlib.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dagre@0.8.5/dist/dagre.min.js"></script>
<!-- dagre layout for hierarchical layout -->
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>