feat: render interactive Mermaid diagrams

This commit is contained in:
lofyer
2026-08-13 00:30:47 +08:00
parent 86b63406c2
commit 40696d9ac7
13 changed files with 2016 additions and 27 deletions
+646 -1
View File
@@ -15,12 +15,14 @@
"@wecom/aibot-node-sdk": "^1.0.6",
"cross-spawn": "^7.0.6",
"dingtalk-stream": "^2.1.6-beta.1",
"dompurify": "^3.4.13",
"fflate": "^0.8.3",
"html-to-text": "^10.0.0",
"i18next": "^25.10.10",
"json5": "^2.2.3",
"katex": "^0.16.47",
"lucide-react": "^1.27.0",
"mermaid": "^11.16.1",
"onnxruntime-web": "^1.23.2",
"pdfjs-dist": "^6.2.108",
"ppu-paddle-ocr": "^6.4.0",
@@ -76,6 +78,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
"license": "MIT",
"dependencies": {
"package-manager-detector": "^1.3.0",
"tinyexec": "^1.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@antv/algorithm": {
"version": "0.1.26",
"resolved": "https://registry.npmjs.org/@antv/algorithm/-/algorithm-0.1.26.tgz",
@@ -671,6 +686,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@braintree/sanitize-url": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
"integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -684,6 +705,12 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@chevrotain/types": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"license": "Apache-2.0"
},
"node_modules/@continuedev/cli": {
"version": "1.5.47",
"resolved": "https://registry.npmjs.org/@continuedev/cli/-/cli-1.5.47.tgz",
@@ -1929,6 +1956,23 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz",
"integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==",
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.1.0",
"@iconify/types": "^2.0.0",
"import-meta-resolve": "^4.2.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
@@ -2285,6 +2329,15 @@
"node": ">=10"
}
},
"node_modules/@mermaid-js/parser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
"license": "MIT",
"dependencies": {
"@chevrotain/types": "~11.1.2"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
@@ -3337,24 +3390,111 @@
"@types/node": "*"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
@@ -3418,6 +3558,12 @@
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
@@ -3445,6 +3591,12 @@
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -3460,12 +3612,37 @@
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -3629,6 +3806,13 @@
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -3884,6 +4068,16 @@
"integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
"license": "ISC"
},
"node_modules/@upsetjs/venn.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
"integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
"license": "MIT",
"optionalDependencies": {
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -5120,6 +5314,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cose-base": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
"integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==",
"license": "MIT",
"dependencies": {
"layout-base": "^1.0.0"
}
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
@@ -5179,6 +5382,95 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/cytoscape": {
"version": "3.34.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.1.tgz",
"integrity": "sha512-Lr0RvH9H75y9ar8h9Toy6u4lxRSCcxUq+hHcQ26sVWo6BnaQp1gwEZOYqwuYTZhyW7npyKnNLP8oJ2p1/3OZ7g==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/cytoscape-cose-bilkent": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz",
"integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==",
"license": "MIT",
"dependencies": {
"cose-base": "^1.0.0"
},
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/cytoscape-fcose": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
"integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
"license": "MIT",
"dependencies": {
"cose-base": "^2.2.0"
},
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/cytoscape-fcose/node_modules/cose-base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz",
"integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==",
"license": "MIT",
"dependencies": {
"layout-base": "^2.0.0"
}
},
"node_modules/cytoscape-fcose/node_modules/layout-base": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz",
"integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -5191,12 +5483,49 @@
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-binarytree": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
"license": "MIT"
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -5206,6 +5535,30 @@
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
@@ -5215,6 +5568,19 @@
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
@@ -5399,6 +5765,15 @@
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
@@ -5423,6 +5798,46 @@
"integrity": "sha512-PF8GWEL70cHHWpx2jUQXc68r1pyPHIA+St16muk/XRokETzlegj5LriNKg7o4LR0TySug4nHYPJNNRz/W+/Niw==",
"license": "BSD-3-Clause"
},
"node_modules/d3-sankey": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz",
"integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "1 - 2",
"d3-shape": "^1.2.0"
}
},
"node_modules/d3-sankey/node_modules/d3-array": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
"license": "BSD-3-Clause",
"dependencies": {
"internmap": "^1.0.0"
}
},
"node_modules/d3-sankey/node_modules/d3-path": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
"license": "BSD-3-Clause"
},
"node_modules/d3-sankey/node_modules/d3-shape": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-path": "1"
}
},
"node_modules/d3-sankey/node_modules/internmap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
"license": "ISC"
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -5452,6 +5867,15 @@
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -5497,6 +5921,41 @@
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
@@ -5507,6 +5966,16 @@
"lodash": "^4.17.15"
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
"integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
"lodash-es": "^4.17.21"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5536,6 +6005,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -5675,6 +6150,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delaunator": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -5856,6 +6340,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -6245,6 +6738,17 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types"
]
},
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
@@ -7254,6 +7758,12 @@
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
"license": "ISC"
},
"node_modules/hachure-fill": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
"license": "MIT"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -7789,6 +8299,16 @@
"node": ">= 4"
}
},
"node_modules/import-meta-resolve": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
"integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -8234,6 +8754,17 @@
"json-buffer": "3.0.1"
}
},
"node_modules/khroma": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
"node_modules/layout-base": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
"license": "MIT"
},
"node_modules/lazy-val": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
@@ -8381,6 +8912,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@@ -8737,6 +9280,35 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mermaid": {
"version": "11.16.1",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz",
"integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.2",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.2.0",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.3",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"es-toolkit": "^1.45.1",
"katex": "^0.16.45",
"khroma": "^2.1.0",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
}
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -10050,6 +10622,12 @@
"node": ">=6"
}
},
"node_modules/package-manager-detector": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz",
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
"license": "MIT"
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
@@ -10116,6 +10694,12 @@
"node": ">= 0.8"
}
},
"node_modules/path-data-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
"integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -10323,6 +10907,22 @@
"node": ">=14.19.0"
}
},
"node_modules/points-on-curve": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
"integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==",
"license": "MIT"
},
"node_modules/points-on-path": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz",
"integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==",
"license": "MIT",
"dependencies": {
"path-data-parser": "0.1.0",
"points-on-curve": "0.2.0"
}
},
"node_modules/postcss": {
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
@@ -11194,6 +11794,12 @@
"node": ">=8.0"
}
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz",
@@ -11239,6 +11845,18 @@
"fsevents": "~2.3.2"
}
},
"node_modules/roughjs": {
"version": "4.6.6",
"resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz",
"integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==",
"license": "MIT",
"dependencies": {
"hachure-fill": "^0.5.2",
"path-data-parser": "^0.1.0",
"points-on-curve": "^0.2.0",
"points-on-path": "^0.2.1"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -11753,6 +12371,12 @@
"inline-style-parser": "0.2.7"
}
},
"node_modules/stylis": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT"
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -11885,7 +12509,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -12036,6 +12659,15 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-dedent": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz",
"integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==",
"license": "MIT",
"engines": {
"node": ">=6.10"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -12427,6 +13059,19 @@
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+14
View File
@@ -120,6 +120,18 @@
{
"from": "node_modules/katex/LICENSE",
"to": "licenses/katex-MIT.txt"
},
{
"from": "node_modules/mermaid/LICENSE",
"to": "licenses/mermaid-MIT.txt"
},
{
"from": "node_modules/dompurify/LICENSE",
"to": "licenses/dompurify-Apache-2.0.txt"
},
{
"from": "node_modules/dompurify/LICENSE-MPL",
"to": "licenses/dompurify-MPL-2.0.txt"
}
],
"win": {
@@ -165,12 +177,14 @@
"@wecom/aibot-node-sdk": "^1.0.6",
"cross-spawn": "^7.0.6",
"dingtalk-stream": "^2.1.6-beta.1",
"dompurify": "^3.4.13",
"fflate": "^0.8.3",
"html-to-text": "^10.0.0",
"i18next": "^25.10.10",
"json5": "^2.2.3",
"katex": "^0.16.47",
"lucide-react": "^1.27.0",
"mermaid": "^11.16.1",
"onnxruntime-web": "^1.23.2",
"pdfjs-dist": "^6.2.108",
"ppu-paddle-ocr": "^6.4.0",
+76
View File
@@ -4314,6 +4314,82 @@ describe('App', () => {
).not.toBeInTheDocument()
})
it('excludes ignored heartbeat suggestions from the navigation badge', async () => {
const heartbeatId = '00000000-0000-4000-8000-000000000701'
const cancelledTaskId = '00000000-0000-4000-8000-000000000801'
const pendingTaskId = '00000000-0000-4000-8000-000000000802'
vi.mocked(api.heartbeats.list).mockResolvedValue([
{
id: heartbeatId,
projectId,
name: '每日回顾',
timezone: 'Asia/Shanghai',
recurrence: { type: 'daily', localTime: '09:00' },
enabled: true,
lookbackHours: 24,
retentionDays: 30,
nextRunAt: '2026-08-05T01:00:00.000Z',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
])
vi.mocked(api.heartbeats.history).mockResolvedValue({
runs: [],
entries: [
{
id: '00000000-0000-4000-8000-000000000901',
configId: heartbeatId,
runId: '00000000-0000-4000-8000-000000000902',
scheduledFor: '2026-08-04T01:00:00.000Z',
summary: '建议处理两个后续行动。',
highlights: [],
proposedMemoryIds: [],
followUpTaskIds: [cancelledTaskId, pendingTaskId],
createdAt: '2026-08-04T01:00:00.000Z'
}
]
})
vi.mocked(api.tasks.list).mockResolvedValue([
{
id: cancelledTaskId,
projectId,
title: '已忽略建议',
instructions: '无需继续处理。',
origin: 'assistant',
status: 'cancelled',
createdAt: '2026-08-04T01:00:00.000Z'
},
{
id: pendingTaskId,
projectId,
title: '待处理建议',
instructions: '继续处理此建议。',
origin: 'assistant',
status: 'paused',
createdAt: '2026-08-04T01:00:00.000Z'
}
])
try {
render(<App />)
expect(
await screen.findByLabelText('1 条待处理建议')
).toBeInTheDocument()
expect(
screen.queryByLabelText('2 条待处理建议')
).not.toBeInTheDocument()
} finally {
cleanup()
vi.mocked(api.heartbeats.list).mockResolvedValue([])
vi.mocked(api.heartbeats.history).mockResolvedValue({
runs: [],
entries: []
})
vi.mocked(api.tasks.list).mockResolvedValue([])
}
})
it('shows retryable page-local knowledge errors without an empty-state flash', async () => {
vi.mocked(api.knowledge.getSnapshot).mockRejectedValueOnce(
new Error('知识数据库暂时不可用')
+3 -1
View File
@@ -2120,7 +2120,9 @@ function App(): React.JSX.Element {
).length +
assistantTasks.filter(
(task) =>
taskIds.has(task.id) && task.status !== 'completed'
taskIds.has(task.id) &&
task.status !== 'completed' &&
task.status !== 'cancelled'
).length
)
}, [assistantMemories, assistantTasks, heartbeatEntries])
+3 -16
View File
@@ -13,6 +13,7 @@ import type {
KnowledgeGraphNode,
KnowledgeGraphRelation
} from '../../shared/contracts'
import { useDocumentTheme } from './use-document-theme'
type ChartKnowledgeGraphNode = Omit<
KnowledgeGraphNode,
@@ -347,7 +348,7 @@ export function KnowledgeGraphChart({
() => graphRevision(nodes, relations),
[nodes, relations]
)
const [themeRevision, setThemeRevision] = useState(0)
const documentTheme = useDocumentTheme()
const [renderError, setRenderError] = useState<string>()
useEffect(() => {
@@ -407,20 +408,6 @@ export function KnowledgeGraphChart({
})
}, [dataRevision, fitViewRequest, renderErrorFallback])
useEffect(() => {
if (typeof MutationObserver !== 'function') {
return
}
const observer = new MutationObserver(() => {
setThemeRevision((revision) => revision + 1)
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
})
return () => observer.disconnect()
}, [])
useEffect(() => {
const container = containerRef.current
if (!container) {
@@ -583,7 +570,7 @@ export function KnowledgeGraphChart({
locale,
relationFallback,
renderErrorFallback,
themeRevision
documentTheme
])
useEffect(() => {
+314 -3
View File
@@ -1,10 +1,45 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest'
import { changeUiLocale } from './i18n'
import { MarkdownRenderer } from './MarkdownRenderer'
const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn()
}))
vi.mock('mermaid', () => ({
default: mermaidMock
}))
describe('MarkdownRenderer', () => {
afterEach(cleanup)
beforeEach(() => {
mermaidMock.render.mockResolvedValue({
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
<rect id="safe-node" width="100" height="50" />
</svg>`
})
})
afterEach(() => {
cleanup()
delete document.documentElement.dataset.theme
vi.clearAllMocks()
})
it('renders CommonMark and GitHub Flavored Markdown', () => {
render(
@@ -165,6 +200,282 @@ $\\href{javascript:alert(1)}{unsafe}$`}</MarkdownRenderer>
).not.toBeInTheDocument()
})
it('renders Mermaid fences with strict bounded configuration', async () => {
const { container } = render(
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
Start --> Finish
\`\`\``}</MarkdownRenderer>
)
expect(
screen.getByText('正在绘制 Mermaid 图表…')
).toHaveAttribute('role', 'status')
const diagram = await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
expect(diagram).toHaveAttribute('tabindex', '0')
expect(container.querySelector('#safe-node')).toBeInTheDocument()
expect(mermaidMock.render).toHaveBeenCalledWith(
expect.stringMatching(/^goodbuddy-mermaid-\d+-0$/u),
'flowchart LR\n Start --> Finish'
)
expect(mermaidMock.initialize).toHaveBeenCalledWith(
expect.objectContaining({
htmlLabels: false,
maxEdges: 300,
maxTextSize: 20_000,
securityLevel: 'strict',
startOnLoad: false,
suppressErrorRendering: true,
theme: 'default'
})
)
})
it('shows source and opens an accessible zoomable Mermaid viewer', async () => {
render(
<div className="app-shell">
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
Start --> Finish
\`\`\``}</MarkdownRenderer>
</div>
)
await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
const sourceButton = screen.getByRole('button', {
name: '查看源码'
})
expect(sourceButton).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(sourceButton)
expect(
screen.getByRole('button', { name: '隐藏源码' })
).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText(/Start --> Finish/u)).toBeInTheDocument()
const viewerButton = screen.getByRole('button', {
name: '打开大图'
})
viewerButton.focus()
fireEvent.click(viewerButton)
const dialog = screen.getByRole('dialog', {
name: 'Mermaid 大图'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(
screen.getByRole('button', { name: '关闭 Mermaid 大图' })
).toHaveFocus()
expect(
document.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(true)
fireEvent.click(screen.getByRole('button', { name: '放大图表' }))
expect(screen.getByLabelText('当前缩放比例')).toHaveTextContent(
'125%'
)
const canvas = screen.getByRole('region', {
name: '可缩放、可拖动的 Mermaid 图表'
})
expect(canvas).toHaveAttribute('tabindex', '0')
canvas.focus()
expect(canvas).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' })
expect(
screen.getByRole('button', { name: '缩小图表' })
).toHaveFocus()
fireEvent.wheel(canvas, { deltaY: -100 })
fireEvent.wheel(canvas, { deltaY: -100 })
fireEvent.wheel(canvas, { deltaY: -100 })
await waitFor(() =>
expect(screen.getByLabelText('当前缩放比例')).toHaveTextContent(
'200%'
)
)
fireEvent.click(screen.getByRole('button', { name: '重置缩放' }))
expect(screen.getByLabelText('当前缩放比例')).toHaveTextContent(
'100%'
)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(viewerButton).toHaveFocus())
expect(
document.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(false)
})
it('closes Mermaid controls when streamed source changes', async () => {
const { rerender } = render(
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
A --> B
\`\`\``}</MarkdownRenderer>
)
await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
fireEvent.click(screen.getByRole('button', { name: '查看源码' }))
fireEvent.click(screen.getByRole('button', { name: '打开大图' }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
rerender(
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
A --> B --> C
\`\`\``}</MarkdownRenderer>
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
expect(
screen.getByRole('button', { name: '查看源码' })
).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText(/A --> B --> C/u)).not.toBeInTheDocument()
})
it('pans the large Mermaid diagram with pointer dragging', async () => {
render(
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
A --> B
\`\`\``}</MarkdownRenderer>
)
await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
fireEvent.click(screen.getByRole('button', { name: '打开大图' }))
const canvas = screen.getByRole('region', {
name: '可缩放、可拖动的 Mermaid 图表'
})
const setPointerCapture = vi.fn()
const releasePointerCapture = vi.fn()
Object.defineProperties(canvas, {
setPointerCapture: { value: setPointerCapture },
hasPointerCapture: { value: () => true },
releasePointerCapture: { value: releasePointerCapture }
})
canvas.scrollLeft = 30
canvas.scrollTop = 40
fireEvent.pointerDown(canvas, {
button: 0,
clientX: 100,
clientY: 100,
pointerId: 9
})
fireEvent.pointerMove(canvas, {
clientX: 70,
clientY: 60,
pointerId: 9
})
expect(setPointerCapture).toHaveBeenCalledWith(9)
expect(canvas).toHaveClass('mermaid-viewer__canvas--dragging')
expect(canvas.scrollLeft).toBe(60)
expect(canvas.scrollTop).toBe(80)
fireEvent.pointerUp(canvas, { pointerId: 9 })
expect(releasePointerCapture).toHaveBeenCalledWith(9)
expect(canvas).not.toHaveClass(
'mermaid-viewer__canvas--dragging'
)
})
it('sanitizes Mermaid SVG without enabling diagram interactions', async () => {
mermaidMock.render.mockResolvedValueOnce({
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
<style>@\\69mport url("https://unsafe.test/style.css");</style>
<script>window.bad = true</script>
<a href="javascript:alert(1)">
<rect id="unsafe-node" onclick="alert(1)"
style="fill: url(javascript:alert(1))" width="100" height="50" />
</a>
<rect id="safe-node" fill="url(#gradient)" width="20" height="20" />
</svg>`
})
const { container } = render(
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
A --> B
\`\`\``}</MarkdownRenderer>
)
await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
expect(container.querySelector('script')).not.toBeInTheDocument()
expect(container.querySelector('a')).not.toBeInTheDocument()
expect(container.querySelector('style')).not.toBeInTheDocument()
expect(container.querySelector('#unsafe-node')).not.toHaveAttribute(
'onclick'
)
expect(container.querySelector('#unsafe-node')).not.toHaveAttribute(
'style'
)
expect(container.querySelector('#safe-node')).toHaveAttribute(
'fill',
'url(#gradient)'
)
})
it('rerenders Mermaid diagrams when the application theme changes', async () => {
render(
<MarkdownRenderer>{`\`\`\`mermaid
sequenceDiagram
Alice->>Bob: Hello
\`\`\``}</MarkdownRenderer>
)
await waitFor(() =>
expect(mermaidMock.render).toHaveBeenCalledTimes(1)
)
act(() => {
document.documentElement.dataset.theme = 'dark'
})
await waitFor(() =>
expect(mermaidMock.render).toHaveBeenCalledTimes(2)
)
expect(mermaidMock.initialize).toHaveBeenLastCalledWith(
expect.objectContaining({
darkMode: true,
theme: 'dark'
})
)
})
it('shows Mermaid source when rendering fails or input is oversized', async () => {
mermaidMock.render.mockRejectedValueOnce(
new Error('Invalid Mermaid syntax')
)
const { rerender } = render(
<MarkdownRenderer>{`\`\`\`mermaid
not a diagram
\`\`\``}</MarkdownRenderer>
)
expect(
await screen.findByRole('alert')
).toHaveTextContent('无法绘制 Mermaid 图表')
expect(screen.getByText('not a diagram')).toBeInTheDocument()
rerender(
<MarkdownRenderer>{`\`\`\`mermaid
${'A'.repeat(20_001)}
\`\`\``}</MarkdownRenderer>
)
expect(
await screen.findByRole('alert')
).toHaveTextContent('无法绘制 Mermaid 图表')
expect(mermaidMock.render).toHaveBeenCalledTimes(1)
})
it('updates table accessibility copy when the locale changes', async () => {
render(
<MarkdownRenderer>{`| Name |
+57 -3
View File
@@ -1,4 +1,11 @@
import { memo, useMemo } from 'react'
import {
Children,
isValidElement,
lazy,
memo,
Suspense,
useMemo
} from 'react'
import rehypeKatex from 'rehype-katex'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -6,6 +13,12 @@ import remarkMath from 'remark-math'
import type { Components } from 'react-markdown'
import { useTranslation } from 'react-i18next'
const MermaidDiagram = lazy(() =>
import('./MermaidDiagram').then((module) => ({
default: module.MermaidDiagram
}))
)
const linkComponent: Components['a'] = ({
children,
node,
@@ -19,9 +32,46 @@ const linkComponent: Components['a'] = ({
)
}
function markdownComponents(tableAriaLabel: string): Components {
function markdownComponents(
tableAriaLabel: string,
mermaidLoadingLabel: string
): Components {
return {
a: linkComponent,
pre: ({ children, node, ...properties }) => {
void node
const child = Children.count(children) === 1
? Children.only(children)
: undefined
if (
isValidElement<{
children?: React.ReactNode
className?: string
}>(child) &&
/(?:^|\s)language-mermaid(?:\s|$)/iu.test(
child.props.className ?? ''
)
) {
const source = String(child.props.children ?? '').replace(
/\n$/u,
''
)
return (
<Suspense
fallback={
<figure aria-busy="true" className="mermaid-diagram">
<figcaption role="status">
{mermaidLoadingLabel}
</figcaption>
</figure>
}
>
<MermaidDiagram source={source} />
</Suspense>
)
}
return <pre {...properties}>{children}</pre>
},
table: ({ children, node, ...properties }) => {
void node
return (
@@ -149,7 +199,11 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
}: MarkdownRendererProps): React.JSX.Element {
const { t } = useTranslation('app')
const components = useMemo(
() => markdownComponents(t('markdown.scrollableTable')),
() =>
markdownComponents(
t('markdown.scrollableTable'),
t('markdown.mermaidLoading')
),
[t]
)
+617
View File
@@ -0,0 +1,617 @@
import DOMPurify from 'dompurify'
import {
Code2,
Maximize2,
RotateCcw,
X,
ZoomIn,
ZoomOut
} from 'lucide-react'
import {
useEffect,
useId,
useMemo,
useRef,
useState
} from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type { Mermaid, MermaidConfig } from 'mermaid'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { useDocumentTheme } from './use-document-theme'
import './markdown-mermaid.css'
const MAX_MERMAID_TEXT_SIZE = 20_000
const MAX_MERMAID_EDGES = 300
const MAX_MERMAID_DIMENSION = 4_096
const MIN_VIEWER_ZOOM = 0.5
const MAX_VIEWER_ZOOM = 3
const VIEWER_ZOOM_STEP = 0.25
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
const unsafeCssPattern =
/(?:@import|\bexpression\s*\(|(?:https?|ftp|file|data|javascript|vbscript):|\/\/|-moz-binding|\bbehavior\s*:)/iu
const cssUrlPattern = /url\s*\(\s*(['"]?)(.*?)\1\s*\)/giu
const forbiddenTags = [
'a',
'animate',
'animatemotion',
'animatetransform',
'discard',
'foreignobject',
'iframe',
'image',
'mpath',
'set',
'script'
]
const forbiddenAttributes = ['href', 'xlink:href']
const sharedSanitizerConfig = {
ALLOW_DATA_ATTR: false,
ALLOW_UNKNOWN_PROTOCOLS: false,
FORBID_ATTR: forbiddenAttributes,
FORBID_TAGS: forbiddenTags
} as const
const secureConfigKeys = [
'secure',
'securityLevel',
'startOnLoad',
'maxTextSize',
'suppressErrorRendering',
'maxEdges',
'htmlLabels',
'dompurifyConfig',
'theme',
'themeCSS',
'themeVariables',
'fontFamily',
'altFontFamily',
'flowchart'
]
let mermaidModulePromise: Promise<Mermaid> | undefined
let mermaidRenderQueue = Promise.resolve()
let mermaidDiagramSequence = 0
type MermaidRenderState =
| {
source: string
status: 'ready'
svg: string
theme: 'light' | 'dark'
}
| {
source: string
status: 'error'
theme: 'light' | 'dark'
}
function loadMermaid(): Promise<Mermaid> {
mermaidModulePromise ??= import('mermaid').then(
(module) => module.default
)
return mermaidModulePromise
}
function containsUnsafeCss(value: string): boolean {
const normalizedValue = value
.replace(/\/\*[\s\S]*?\*\//gu, '')
.replace(
/\\([0-9a-f]{1,6}\s?|.)/giu,
(_match, escaped: string) => {
const hex = escaped.trim()
return /^[0-9a-f]{1,6}$/iu.test(hex)
? String.fromCodePoint(Number.parseInt(hex, 16))
: escaped
}
)
if (unsafeCssPattern.test(normalizedValue)) {
return true
}
cssUrlPattern.lastIndex = 0
for (
let match = cssUrlPattern.exec(normalizedValue);
match;
match = cssUrlPattern.exec(normalizedValue)
) {
if (!match[2]?.trim().startsWith('#')) {
return true
}
}
return false
}
function sanitizeMermaidSvg(svg: string): string {
const sanitized = DOMPurify.sanitize(svg, {
...sharedSanitizerConfig,
USE_PROFILES: {
svg: true,
svgFilters: true
}
})
const documentNode = new DOMParser().parseFromString(
sanitized,
'image/svg+xml'
)
const root = documentNode.documentElement
if (
root.localName !== 'svg' ||
root.namespaceURI !== SVG_NAMESPACE ||
documentNode.querySelector('parsererror')
) {
throw new Error('Mermaid returned invalid SVG')
}
for (const element of [root, ...root.querySelectorAll('*')]) {
if (
element.localName === 'style' &&
containsUnsafeCss(element.textContent ?? '')
) {
element.remove()
continue
}
for (const attribute of [...element.attributes]) {
const attributeName = attribute.name.toLowerCase()
if (
attributeName.startsWith('on') ||
forbiddenAttributes.includes(attributeName) ||
containsUnsafeCss(attribute.value)
) {
element.removeAttribute(attribute.name)
}
}
}
const viewBox = root
.getAttribute('viewBox')
?.trim()
.split(/[\s,]+/u)
.map(Number)
if (
viewBox?.length === 4 &&
viewBox.every(Number.isFinite) &&
viewBox[2]! > 0 &&
viewBox[3]! > 0
) {
const scale = Math.min(
1,
MAX_MERMAID_DIMENSION / viewBox[2]!,
MAX_MERMAID_DIMENSION / viewBox[3]!
)
root.setAttribute('width', String(Math.ceil(viewBox[2]! * scale)))
root.setAttribute('height', String(Math.ceil(viewBox[3]! * scale)))
root.style.removeProperty('max-width')
}
return new XMLSerializer().serializeToString(root)
}
function mermaidConfig(darkTheme: boolean): MermaidConfig {
return {
darkMode: darkTheme,
deterministicIds: true,
dompurifyConfig: sharedSanitizerConfig,
fontFamily:
'"Inter Variable", "Noto Sans SC Variable", "Segoe UI Variable", sans-serif',
htmlLabels: false,
logLevel: 'fatal',
maxEdges: MAX_MERMAID_EDGES,
maxTextSize: MAX_MERMAID_TEXT_SIZE,
secure: secureConfigKeys,
securityLevel: 'strict',
startOnLoad: false,
suppressErrorRendering: true,
theme: darkTheme ? 'dark' : 'default'
}
}
function renderMermaid(
source: string,
id: string,
darkTheme: boolean,
shouldRender: () => boolean
): Promise<string> {
if (source.length > MAX_MERMAID_TEXT_SIZE) {
return Promise.reject(new Error('Mermaid source is too large'))
}
const render = mermaidRenderQueue.then(async () => {
if (!shouldRender()) {
throw new Error('Mermaid render was superseded')
}
const mermaid = await loadMermaid()
if (!shouldRender()) {
throw new Error('Mermaid render was superseded')
}
mermaid.initialize(mermaidConfig(darkTheme))
const result = await mermaid.render(id, source)
return sanitizeMermaidSvg(result.svg)
})
mermaidRenderQueue = render.then(
() => undefined,
() => undefined
)
return render
}
type MermaidDiagramProps = {
source: string
}
type MermaidViewerProps = {
onClose: () => void
svg: string
}
function clampViewerZoom(zoom: number): number {
return Math.min(MAX_VIEWER_ZOOM, Math.max(MIN_VIEWER_ZOOM, zoom))
}
function MermaidViewer({
onClose,
svg
}: MermaidViewerProps): React.JSX.Element {
const { i18n, t } = useTranslation('app')
const titleId = useId()
const hintId = useId()
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
const dragRef = useRef<{
pointerId: number
startX: number
startY: number
scrollLeft: number
scrollTop: number
} | undefined>(undefined)
const [dragging, setDragging] = useState(false)
const [zoom, setZoom] = useState(1)
const pendingWheelZoomRef = useRef(0)
const zoomFrameRef = useRef<number | undefined>(undefined)
const percentFormatter = useMemo(
() =>
new Intl.NumberFormat(i18n.resolvedLanguage, {
maximumFractionDigits: 0,
style: 'percent'
}),
[i18n.resolvedLanguage]
)
useEffect(() => {
const deactivateModalFocus = activateModalFocus(
() => closeRef.current
)
return () => {
if (zoomFrameRef.current !== undefined) {
cancelAnimationFrame(zoomFrameRef.current)
}
deactivateModalFocus()
}
}, [])
const adjustZoom = (delta: number): void => {
setZoom((current) => clampViewerZoom(current + delta))
}
const queueWheelZoom = (delta: number): void => {
pendingWheelZoomRef.current += delta
if (zoomFrameRef.current !== undefined) {
return
}
zoomFrameRef.current = requestAnimationFrame(() => {
const pendingZoom = pendingWheelZoomRef.current
pendingWheelZoomRef.current = 0
zoomFrameRef.current = undefined
adjustZoom(pendingZoom)
})
}
const finishDragging = (
event: React.PointerEvent<HTMLDivElement>
): void => {
if (dragRef.current?.pointerId !== event.pointerId) {
return
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
dragRef.current = undefined
setDragging(false)
}
return createPortal(
<div
className="mermaid-viewer-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<section
aria-describedby={hintId}
aria-labelledby={titleId}
aria-modal="true"
className="mermaid-viewer"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
>
<header className="mermaid-viewer__header">
<div>
<strong id={titleId}>{t('markdown.mermaidViewerTitle')}</strong>
<small id={hintId}>
{t('markdown.mermaidViewerHint')}
</small>
</div>
<div className="mermaid-viewer__controls">
<button
aria-label={t('markdown.mermaidZoomOut')}
className="icon-button"
disabled={zoom <= MIN_VIEWER_ZOOM}
onClick={() => adjustZoom(-VIEWER_ZOOM_STEP)}
title={t('markdown.mermaidZoomOut')}
type="button"
>
<ZoomOut aria-hidden="true" size={17} />
</button>
<output
aria-label={t('markdown.mermaidZoomLevel')}
className="mermaid-viewer__zoom"
>
{percentFormatter.format(zoom)}
</output>
<button
aria-label={t('markdown.mermaidZoomIn')}
className="icon-button"
disabled={zoom >= MAX_VIEWER_ZOOM}
onClick={() => adjustZoom(VIEWER_ZOOM_STEP)}
title={t('markdown.mermaidZoomIn')}
type="button"
>
<ZoomIn aria-hidden="true" size={17} />
</button>
<button
aria-label={t('markdown.mermaidResetZoom')}
className="icon-button"
disabled={zoom === 1}
onClick={() => setZoom(1)}
title={t('markdown.mermaidResetZoom')}
type="button"
>
<RotateCcw aria-hidden="true" size={16} />
</button>
<button
aria-label={t('markdown.mermaidCloseViewer')}
className="icon-button"
onClick={onClose}
ref={closeRef}
title={t('markdown.mermaidCloseViewer')}
type="button"
>
<X aria-hidden="true" size={18} />
</button>
</div>
</header>
<div
aria-label={t('markdown.mermaidViewerCanvas')}
className={`mermaid-viewer__canvas${
dragging ? ' mermaid-viewer__canvas--dragging' : ''
}`}
onPointerCancel={finishDragging}
onPointerDown={(event) => {
if (event.button !== 0) {
return
}
event.preventDefault()
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
scrollLeft: event.currentTarget.scrollLeft,
scrollTop: event.currentTarget.scrollTop
}
event.currentTarget.setPointerCapture(event.pointerId)
setDragging(true)
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (drag?.pointerId !== event.pointerId) {
return
}
event.preventDefault()
event.currentTarget.scrollLeft =
drag.scrollLeft - (event.clientX - drag.startX)
event.currentTarget.scrollTop =
drag.scrollTop - (event.clientY - drag.startY)
}}
onPointerUp={finishDragging}
onWheel={(event) => {
if (event.deltaY === 0) {
return
}
event.preventDefault()
queueWheelZoom(
event.deltaY < 0
? VIEWER_ZOOM_STEP
: -VIEWER_ZOOM_STEP
)
}}
role="region"
tabIndex={0}
>
<div
className="mermaid-viewer__diagram"
dangerouslySetInnerHTML={{ __html: svg }}
style={{ zoom }}
/>
</div>
</section>
</div>,
document.body
)
}
export function MermaidDiagram({
source
}: MermaidDiagramProps): React.JSX.Element {
const { t } = useTranslation('app')
const idRef = useRef(
`goodbuddy-mermaid-${++mermaidDiagramSequence}`
)
const renderRevisionRef = useRef(0)
const sourceId = useId()
const documentTheme = useDocumentTheme()
const [renderState, setRenderState] =
useState<MermaidRenderState>()
const [visibleSource, setVisibleSource] = useState<string>()
const [viewerSource, setViewerSource] = useState<string>()
const sourceVisible = visibleSource === source
const viewerOpen = viewerSource === source
useEffect(() => {
let active = true
const renderRevision = renderRevisionRef.current++
const timeout = window.setTimeout(() => {
void renderMermaid(
source.trim(),
`${idRef.current}-${renderRevision}`,
documentTheme === 'dark',
() => active
).then(
(svg) => {
if (active) {
setRenderState({
source,
status: 'ready',
svg,
theme: documentTheme
})
}
},
() => {
if (active) {
setRenderState({
source,
status: 'error',
theme: documentTheme
})
}
}
)
}, 120)
return () => {
active = false
window.clearTimeout(timeout)
}
}, [documentTheme, source])
const currentRenderState =
renderState?.source === source &&
renderState.theme === documentTheme
? renderState
: undefined
const retainedSvg =
renderState?.status === 'ready' &&
renderState.source === source
? renderState.svg
: undefined
const svg =
currentRenderState?.status === 'ready'
? currentRenderState.svg
: retainedSvg
if (currentRenderState?.status === 'error') {
return (
<figure className="mermaid-diagram mermaid-diagram--error">
<figcaption role="alert">
{t('markdown.mermaidError')}
</figcaption>
<pre>
<code className="language-mermaid">{source}</code>
</pre>
</figure>
)
}
return (
<figure
aria-busy={!currentRenderState}
className="mermaid-diagram"
>
{svg ? (
<>
<div
aria-label={t('markdown.mermaidActions')}
className="mermaid-diagram__actions"
role="group"
>
<button
aria-controls={sourceId}
aria-expanded={sourceVisible}
aria-label={t(
sourceVisible
? 'markdown.mermaidHideSource'
: 'markdown.mermaidViewSource'
)}
className={`icon-button${
sourceVisible ? ' icon-button--active' : ''
}`}
onClick={() =>
setVisibleSource(sourceVisible ? undefined : source)
}
title={t(
sourceVisible
? 'markdown.mermaidHideSource'
: 'markdown.mermaidViewSource'
)}
type="button"
>
<Code2 aria-hidden="true" size={17} />
</button>
<button
aria-label={t('markdown.mermaidOpenViewer')}
className="icon-button"
onClick={() => setViewerSource(source)}
title={t('markdown.mermaidOpenViewer')}
type="button"
>
<Maximize2 aria-hidden="true" size={17} />
</button>
</div>
{!viewerOpen && (
<div
aria-label={t('markdown.mermaidDiagram')}
className="mermaid-diagram__viewport"
dangerouslySetInnerHTML={{ __html: svg }}
role="region"
tabIndex={0}
/>
)}
{sourceVisible && (
<pre className="mermaid-diagram__source" id={sourceId}>
<code className="language-mermaid">{source}</code>
</pre>
)}
{viewerOpen && (
<MermaidViewer
onClose={() => setViewerSource(undefined)}
svg={svg}
/>
)}
</>
) : (
<figcaption className="mermaid-diagram__loading" role="status">
{t('markdown.mermaidLoading')}
</figcaption>
)}
</figure>
)
}
+1 -1
View File
@@ -5,7 +5,7 @@ type TabKeyEvent = {
}
const focusableSelector =
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])'
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
export function trapTabFocus(
event: TabKeyEvent,
+18 -1
View File
@@ -473,6 +473,23 @@ export const app = {
evidenceExcerpt: '{{source}}: {{excerpt}}'
},
markdown: {
scrollableTable: 'Table, horizontally scrollable'
scrollableTable: 'Table, horizontally scrollable',
mermaidDiagram: 'Mermaid diagram, horizontally scrollable',
mermaidLoading: 'Rendering Mermaid diagram…',
mermaidError:
'The Mermaid diagram could not be rendered. Its source is shown below.',
mermaidActions: 'Mermaid diagram actions',
mermaidViewSource: 'View source',
mermaidHideSource: 'Hide source',
mermaidOpenViewer: 'Open large diagram',
mermaidViewerTitle: 'Large Mermaid diagram',
mermaidViewerHint:
'Use the mouse wheel or buttons to zoom, and drag the canvas to pan.',
mermaidViewerCanvas: 'Zoomable, pannable Mermaid diagram',
mermaidZoomOut: 'Zoom out diagram',
mermaidZoomIn: 'Zoom in diagram',
mermaidResetZoom: 'Reset zoom',
mermaidZoomLevel: 'Current zoom level',
mermaidCloseViewer: 'Close large Mermaid diagram'
}
} satisfies TranslationShape<typeof chineseApp>
+16 -1
View File
@@ -439,6 +439,21 @@ export const app = {
evidenceExcerpt: '{{source}}{{excerpt}}'
},
markdown: {
scrollableTable: '表格,可横向滚动'
scrollableTable: '表格,可横向滚动',
mermaidDiagram: 'Mermaid 图表,可横向滚动',
mermaidLoading: '正在绘制 Mermaid 图表…',
mermaidError: '无法绘制 Mermaid 图表,已保留原始图表代码。',
mermaidActions: 'Mermaid 图表操作',
mermaidViewSource: '查看源码',
mermaidHideSource: '隐藏源码',
mermaidOpenViewer: '打开大图',
mermaidViewerTitle: 'Mermaid 大图',
mermaidViewerHint: '使用滚轮或按钮缩放,拖动画布浏览。',
mermaidViewerCanvas: '可缩放、可拖动的 Mermaid 图表',
mermaidZoomOut: '缩小图表',
mermaidZoomIn: '放大图表',
mermaidResetZoom: '重置缩放',
mermaidZoomLevel: '当前缩放比例',
mermaidCloseViewer: '关闭 Mermaid 大图'
}
} as const
+205
View File
@@ -0,0 +1,205 @@
.mermaid-diagram {
position: relative;
display: grid;
min-width: 0;
margin: var(--space-3) 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-raised);
}
.mermaid-diagram__actions {
position: absolute;
z-index: 1;
top: var(--space-2);
right: var(--space-2);
display: flex;
padding: 2px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-raised);
box-shadow: var(--shadow-card);
gap: 2px;
}
.mermaid-diagram__actions .icon-button {
width: 30px;
height: 30px;
}
.mermaid-diagram__viewport {
min-width: 0;
padding: var(--space-4);
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.mermaid-diagram__viewport:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.mermaid-diagram__viewport > svg {
display: block;
min-width: min(100%, 320px);
max-width: none !important;
height: auto;
min-height: 80px;
margin-inline: auto;
font-family: var(--font-family-ui);
}
.markdown-content .mermaid-diagram__source {
max-height: 320px;
margin: 0;
border: 0;
border-top: 1px solid var(--border-subtle);
border-radius: 0 0 var(--radius-control) var(--radius-control);
overflow: auto;
}
.mermaid-diagram__loading,
.mermaid-diagram--error > figcaption {
padding: var(--space-3) var(--space-4);
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.mermaid-diagram--error {
border-color: var(--danger-border);
background: var(--danger-subtle);
}
.mermaid-diagram--error > figcaption {
color: var(--danger-strong);
}
.markdown-content .mermaid-diagram--error > pre {
margin: 0;
border: 0;
border-top: 1px solid var(--danger-border);
border-radius: 0 0 var(--radius-control) var(--radius-control);
}
.mermaid-viewer-backdrop {
position: fixed;
z-index: 120;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.mermaid-viewer {
display: grid;
width: min(1280px, 100%);
height: min(860px, calc(100vh - 70px));
min-height: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
grid-template-rows: auto minmax(0, 1fr);
}
.mermaid-viewer__header {
display: flex;
min-width: 0;
align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
justify-content: space-between;
gap: var(--space-3);
}
.mermaid-viewer__header > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.mermaid-viewer__header strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.mermaid-viewer__header small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.4;
}
.mermaid-viewer__controls {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-1);
}
.mermaid-viewer__zoom {
min-width: 48px;
color: var(--text-secondary);
font-size: var(--font-caption);
font-variant-numeric: tabular-nums;
text-align: center;
}
.mermaid-viewer__canvas {
display: grid;
min-width: 0;
min-height: 0;
padding: var(--space-6);
overflow: auto;
background: var(--surface-subtle);
cursor: grab;
overscroll-behavior: contain;
place-items: center;
touch-action: none;
}
.mermaid-viewer__canvas--dragging {
cursor: grabbing;
user-select: none;
}
.mermaid-viewer__canvas:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.mermaid-viewer__diagram {
display: grid;
min-width: max-content;
min-height: max-content;
transform-origin: center;
place-items: center;
}
.mermaid-viewer__diagram > svg {
display: block;
max-width: none !important;
height: auto;
font-family: var(--font-family-ui);
}
@media (max-width: 720px) {
.mermaid-viewer-backdrop {
padding: var(--space-2);
}
.mermaid-viewer {
height: calc(100vh - 54px);
}
.mermaid-viewer__header {
align-items: flex-start;
flex-direction: column;
}
.mermaid-viewer__controls {
width: 100%;
justify-content: flex-end;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { useSyncExternalStore } from 'react'
import type { ResolvedAppearanceTheme } from './theme'
const listeners = new Set<() => void>()
let observer: MutationObserver | undefined
function resolvedDocumentTheme(): ResolvedAppearanceTheme {
return document.documentElement.dataset.theme === 'dark'
? 'dark'
: 'light'
}
function notifyThemeListeners(): void {
for (const listener of listeners) {
listener()
}
}
function subscribeToDocumentTheme(listener: () => void): () => void {
listeners.add(listener)
if (
listeners.size === 1 &&
typeof MutationObserver === 'function'
) {
observer = new MutationObserver(notifyThemeListeners)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
})
}
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
observer?.disconnect()
observer = undefined
}
}
}
export function useDocumentTheme(): ResolvedAppearanceTheme {
return useSyncExternalStore(
subscribeToDocumentTheme,
resolvedDocumentTheme,
resolvedDocumentTheme
)
}