Add streaming markdown renderer

This commit is contained in:
murashit 2026-06-18 14:38:11 +09:00
parent 07f910dce3
commit 7fff6d1cce
14 changed files with 739 additions and 60 deletions

View file

@ -195,6 +195,7 @@ const pureChatStateRestrictions = [
];
const chatExternalDomBridgeFiles = [
"src/features/chat/ui/message-stream/markdown-renderer.ts",
"src/features/chat/ui/message-stream/stream-markdown-renderer.ts",
"src/features/chat/ui/message-stream/virtualizer.ts",
];
const chatPreactDomBridgeFiles = [

483
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@preact/signals": "^2.9.1",
"@tanstack/query-core": "^5.101.0",
"@tanstack/virtual-core": "3.17.0",
"micromark": "^4.0.2",
"preact": "^10.29.2"
},
"devDependencies": {
@ -2627,6 +2628,15 @@
"@types/tern": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
"integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
"license": "MIT",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -2673,6 +2683,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
@ -3546,6 +3562,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
"integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -3746,7 +3772,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -3767,6 +3792,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
"integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
"license": "MIT",
"dependencies": {
"character-entities": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -3810,6 +3848,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -3820,6 +3867,19 @@
"node": ">=8"
}
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -7015,6 +7075,426 @@
"node": ">= 8"
}
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
"integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"@types/debug": "^4.0.0",
"debug": "^4.0.0",
"decode-named-character-reference": "^1.0.0",
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-decode-numeric-character-reference": "^2.0.0",
"micromark-util-encode": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-subtokenize": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-core-commonmark": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
"integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"decode-named-character-reference": "^1.0.0",
"devlop": "^1.0.0",
"micromark-factory-destination": "^2.0.0",
"micromark-factory-label": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-factory-title": "^2.0.0",
"micromark-factory-whitespace": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-html-tag-name": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-subtokenize": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
"integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-factory-label": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
"integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-factory-space": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
"integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-factory-title": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
"integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-factory-whitespace": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
"integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-character": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-chunked": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
"integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-classify-character": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
"integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-combine-extensions": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
"integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-chunked": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-decode-numeric-character-reference": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
"integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-encode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-html-tag-name": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
"integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-normalize-identifier": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
"integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-resolve-all": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
"integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-sanitize-uri": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-encode": "^2.0.0",
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-subtokenize": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
"integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-symbol": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-types": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -7089,7 +7569,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {

View file

@ -63,6 +63,7 @@
"@preact/signals": "^2.9.1",
"@tanstack/query-core": "^5.101.0",
"@tanstack/virtual-core": "3.17.0",
"micromark": "^4.0.2",
"preact": "^10.29.2"
}
}

View file

@ -6,6 +6,7 @@ import type { ChatAction } from "../../application/state/root-reducer";
import type { ChatStateStore } from "../../application/state/store";
import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "../../ui/message-stream/virtualizer";
import { MarkdownMessageRenderer } from "../../ui/message-stream/markdown-renderer";
import { StreamMarkdownMessageRenderer } from "../../ui/message-stream/stream-markdown-renderer";
import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/viewport";
import { messageStreamBlocks } from "../../ui/message-stream/stream-blocks";
import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../shell-state";
@ -77,14 +78,19 @@ export interface MessageStreamPresenterOptions {
}
export class MessageStreamPresenter {
private readonly markdownRenderer: MarkdownMessageRenderer;
private readonly obsidianMarkdownRenderer: MarkdownMessageRenderer;
private readonly streamMarkdownRenderer: StreamMarkdownMessageRenderer;
constructor(private readonly options: MessageStreamPresenterOptions) {
this.markdownRenderer = new MarkdownMessageRenderer({
this.obsidianMarkdownRenderer = new MarkdownMessageRenderer({
app: options.obsidian.app,
owner: options.obsidian.owner,
vaultPath: options.workspace.vaultPath,
});
this.streamMarkdownRenderer = new StreamMarkdownMessageRenderer({
app: options.obsidian.app,
vaultPath: options.workspace.vaultPath,
});
}
private dispatch(action: ChatAction): void {
@ -114,8 +120,11 @@ export class MessageStreamPresenter {
loadOlderTurns: () => {
this.options.history.loadOlderTurns();
},
renderMarkdown: (element, text) => {
this.markdownRenderer.renderMarkdown(element, text);
renderObsidianMarkdown: (element, text) => {
this.obsidianMarkdownRenderer.renderObsidianMarkdown(element, text);
},
renderStreamMarkdown: (element, text) => {
this.streamMarkdownRenderer.renderStreamMarkdown(element, text);
},
copyMessageText: (text) => void this.copyMessageText(text),
actions: this.options.actions,

View file

@ -42,7 +42,8 @@ export interface ChatMessageStreamSurfaceContext {
setDisclosureOpen: (bucket: ChatDisclosureBucket, id: string, open: boolean) => void;
setForkActionsItem: (itemId: string | null) => void;
loadOlderTurns: () => void;
renderMarkdown: (element: HTMLElement, text: string) => void;
renderObsidianMarkdown: (element: HTMLElement, text: string) => void;
renderStreamMarkdown: (element: HTMLElement, text: string) => void;
copyMessageText: (text: string) => void;
actions: ChatMessageStreamActions;
requests: ChatMessageStreamRequests;
@ -52,7 +53,8 @@ export interface MessageStreamSurfaceContextOptions {
vaultPath: string;
dispatch: (action: ChatAction) => void;
loadOlderTurns: () => void;
renderMarkdown: (element: HTMLElement, text: string) => void;
renderObsidianMarkdown: (element: HTMLElement, text: string) => void;
renderStreamMarkdown: (element: HTMLElement, text: string) => void;
copyMessageText: (text: string) => void;
actions: ChatMessageStreamActions;
requests: ChatMessageStreamRequests;
@ -81,7 +83,8 @@ export function createMessageStreamSurfaceContext(options: MessageStreamSurfaceC
options.dispatch({ type: "ui/message-fork-actions-set", itemId });
},
loadOlderTurns: options.loadOlderTurns,
renderMarkdown: options.renderMarkdown,
renderObsidianMarkdown: options.renderObsidianMarkdown,
renderStreamMarkdown: options.renderStreamMarkdown,
copyMessageText: options.copyMessageText,
actions: options.actions,
requests: options.requests,
@ -111,7 +114,8 @@ function messageStreamContextFromProjection(
forkActionsItemId: projection.forkActionsItemId,
onForkActionsToggle: context.setForkActionsItem,
loadOlderTurns: context.loadOlderTurns,
renderMarkdown: context.renderMarkdown,
renderObsidianMarkdown: context.renderObsidianMarkdown,
renderStreamMarkdown: context.renderStreamMarkdown,
copyText: context.copyMessageText,
onImplementPlan: (target) => {
context.actions.implementPlan(target.itemId);

View file

@ -63,27 +63,29 @@ export interface MessageStreamTextView {
body: string;
className: string;
contentKey: string;
contentMode: "markdown" | "text";
renderMode: MessageStreamTextRenderMode;
collapsible: boolean;
copyText?: string;
actions: MessageStreamTextActions;
metadata: MessageStreamTextMetadataView;
}
type MessageStreamTextRenderMode = "text" | "streamMarkdown" | "obsidianMarkdown";
export function messageStreamTextView(
item: MessageStreamItem,
annotations?: MessageStreamItemAnnotations,
options: { activeTurnId?: string | null; actions?: MessageStreamTextActions } = {},
): MessageStreamTextView {
const contentMode = textContentMode(item);
const renderMode = textRenderMode(item);
const body = bodyForTextItem(item);
return {
id: item.id,
roleLabel: roleLabelForTextItem(item),
body,
className: `${textItemClass(item)}${executionClassName(item.executionState ?? null)}`,
contentKey: `${item.id}\u001f${contentMode}`,
contentMode,
contentKey: `${item.id}\u001f${renderMode}`,
renderMode,
collapsible: item.kind === "message" && item.role === "user",
...definedProp("copyText", copyTextForTextItem(item, options.activeTurnId ?? null)),
actions: options.actions ?? {},
@ -91,8 +93,10 @@ export function messageStreamTextView(
};
}
function textContentMode(item: MessageStreamItem): "markdown" | "text" {
return item.kind === "message" && (item.messageKind !== "proposedPlan" || item.messageState === "completed") ? "markdown" : "text";
function textRenderMode(item: MessageStreamItem): MessageStreamTextRenderMode {
if (item.kind !== "message") return "text";
if (item.messageKind === "assistantResponse" && item.messageState === "streaming") return "streamMarkdown";
return item.messageKind !== "proposedPlan" || item.messageState === "completed" ? "obsidianMarkdown" : "text";
}
function bodyForTextItem(item: MessageStreamItem): string {

View file

@ -41,7 +41,8 @@ export interface TextItemDetailStateContext {
}
export interface TextItemContentContext extends TextItemDetailStateContext {
renderMarkdown: (parent: HTMLElement, text: string) => void;
renderObsidianMarkdown: (parent: HTMLElement, text: string) => void;
renderStreamMarkdown: (parent: HTMLElement, text: string) => void;
}
export interface TextItemActionContext extends TextItemDetailStateContext {

View file

@ -14,7 +14,7 @@ export class MarkdownMessageRenderer {
constructor(private readonly options: MarkdownMessageRendererOptions) {}
renderMarkdown(parent: HTMLElement, text: string): void {
renderObsidianMarkdown(parent: HTMLElement, text: string): void {
const sourcePath = this.options.app.workspace.getActiveFile()?.path ?? "";
const generation = (this.renderGenerations.get(parent) ?? 0) + 1;
this.renderGenerations.set(parent, generation);

View file

@ -0,0 +1,53 @@
import { Notice, type App } from "obsidian";
import { micromark } from "micromark";
import { isAbsoluteFileHref, vaultFileLinkTarget, vaultRelativeFileLinkTarget } from "../../../../shared/obsidian/file-links";
export interface StreamMarkdownMessageRendererOptions {
app: App;
vaultPath: string;
}
export class StreamMarkdownMessageRenderer {
constructor(private readonly options: StreamMarkdownMessageRendererOptions) {}
renderStreamMarkdown(parent: HTMLElement, text: string): void {
const html = micromark(text);
const DOMParserConstructor = parent.ownerDocument.defaultView?.DOMParser ?? DOMParser;
const parser = new DOMParserConstructor();
const parsed = parser.parseFromString(html, "text/html");
const nodes = Array.from(parsed.body.childNodes).map((node) => parent.ownerDocument.importNode(node, true));
parent.replaceChildren(...nodes);
bindStreamMarkdownFileLinks(parent, this.options);
}
}
function bindStreamMarkdownFileLinks(parent: HTMLElement, context: StreamMarkdownMessageRendererOptions): void {
const sourcePath = context.app.workspace.getActiveFile()?.path ?? "";
parent.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((link) => {
const href = link.getAttribute("href") ?? "";
const target = vaultFileLinkTarget(context.app, context.vaultPath, href);
if (target) {
link.addClass("codex-panel__filelink");
link.onclick = (event) => {
event.preventDefault();
void context.app.workspace.openLinkText(target, sourcePath, false);
};
return;
}
const vaultRelativeTarget = vaultRelativeFileLinkTarget(context.vaultPath, context.app.vault.configDir, href);
if (vaultRelativeTarget) {
link.onclick = (event) => {
event.preventDefault();
};
return;
}
if (!isAbsoluteFileHref(href)) return;
link.onclick = (event) => {
event.preventDefault();
new Notice("Cannot open files outside the vault.");
};
});
}

View file

@ -25,7 +25,7 @@ export function CollapsibleTextContent({ view, context }: { view: MessageStreamT
return () => {
content.removeEventListener(MESSAGE_CONTENT_RENDERED_EVENT, update);
};
}, [view.id, view.body, view.contentMode]);
}, [view.id, view.body, view.renderMode]);
useEffect(() => {
if (!overflows || !expanded) return;
@ -76,7 +76,7 @@ interface TextContentProps {
}
export function TextContent({ view, context, contentRef, collapsed = false }: TextContentProps): UiNode {
const rendersMarkdown = view.contentMode === "markdown";
const rendersMarkdown = view.renderMode !== "text";
const text = view.body;
const localRef = useRef<HTMLDivElement | null>(null);
const contextRef = useRef(context);
@ -87,12 +87,14 @@ export function TextContent({ view, context, contentRef, collapsed = false }: Te
const content = localRef.current;
if (!content) return;
const currentContext = contextRef.current;
if (rendersMarkdown) {
currentContext.renderMarkdown(content, text);
if (view.renderMode === "obsidianMarkdown") {
currentContext.renderObsidianMarkdown(content, text);
} else if (view.renderMode === "streamMarkdown") {
currentContext.renderStreamMarkdown(content, text);
} else {
content.textContent = text;
}
}, [view.contentMode, rendersMarkdown, text]);
}, [view.renderMode, text]);
return (
<div
ref={(element) => {

View file

@ -582,7 +582,8 @@ function messageStreamSurfaceContext(options: {
options.dispatch({ type: "ui/message-fork-actions-set", itemId });
},
loadOlderTurns: vi.fn(),
renderMarkdown: vi.fn(),
renderObsidianMarkdown: vi.fn(),
renderStreamMarkdown: vi.fn(),
copyMessageText: vi.fn(),
actions: {
rollbackThread: vi.fn(),
@ -637,7 +638,7 @@ async function renderedInternalLink(
vaultPath: context.vaultPath,
});
markdownRenderer.renderMarkdown(parent, "[[Link]]");
markdownRenderer.renderObsidianMarkdown(parent, "[[Link]]");
await Promise.resolve();
await Promise.resolve();
renderMarkdown.mockRestore();

View file

@ -615,7 +615,7 @@ describe("message stream rendering and message actions", () => {
forkActionsItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (element: HTMLElement, text: string) => {
markdownRenderer.renderMarkdown(element, text);
markdownRenderer.renderObsidianMarkdown(element, text);
},
};
@ -670,24 +670,15 @@ describe("message stream rendering and message actions", () => {
unmountUiRootInAct(parent);
});
it("ignores stale async markdown renders after streaming content updates", async () => {
it("uses stream markdown for streaming assistant responses without calling the Obsidian renderer", async () => {
const parent = document.createElement("div");
const firstRender = deferred<undefined>();
const renderMarkdown = vi.spyOn(MarkdownRenderer, "render");
renderMarkdown
.mockImplementationOnce((_app, text: string, element: HTMLElement) =>
firstRender.promise.then(() => {
element.textContent = `stale:${text}`;
}),
)
.mockImplementation((_app, text: string, element: HTMLElement) => {
element.textContent = `fresh:${text}`;
return Promise.resolve();
});
const markdownRenderer = new MarkdownMessageRenderer({
app: { workspace: { getActiveFile: vi.fn(() => null) } } as never,
owner: {} as never,
vaultPath: "/vault",
renderMarkdown.mockImplementation((_app, text: string, element: HTMLElement) => {
element.textContent = `obsidian:${text}`;
return Promise.resolve();
});
const renderStreamMarkdown = vi.fn((element: HTMLElement, text: string) => {
element.replaceChildren(element.createEl("strong", { text: `stream:${text.replace(/\*/g, "")}` }));
});
const baseContext = {
activeThreadId: "thread",
@ -697,9 +688,8 @@ describe("message stream rendering and message actions", () => {
disclosures: emptyDisclosures(),
forkActionsItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (element: HTMLElement, text: string) => {
markdownRenderer.renderMarkdown(element, text);
},
renderObsidianMarkdown: vi.fn(),
renderStreamMarkdown,
};
renderMessageStreamBlocksInAct(
@ -719,34 +709,59 @@ describe("message stream rendering and message actions", () => {
],
}),
);
const staleContent = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__message-content"));
expect(parent.querySelector(".codex-panel__message-content")?.innerHTML).toBe("<strong>stream:old</strong>");
expect(renderStreamMarkdown).toHaveBeenCalledWith(expect.any(HTMLElement), "old");
expect(baseContext.renderObsidianMarkdown).not.toHaveBeenCalled();
expect(renderMarkdown).not.toHaveBeenCalled();
renderMarkdown.mockRestore();
unmountUiRootInAct(parent);
});
it("uses Obsidian markdown for completed assistant responses", async () => {
const parent = document.createElement("div");
const renderMarkdown = vi.spyOn(MarkdownRenderer, "render");
renderMarkdown.mockImplementationOnce((_app, text: string, element: HTMLElement) => {
element.textContent = `obsidian:${text}`;
return Promise.resolve();
});
const markdownRenderer = new MarkdownMessageRenderer({
app: { workspace: { getActiveFile: vi.fn(() => null) } } as never,
owner: {} as never,
vaultPath: "/vault",
});
renderMessageStreamBlocksInAct(
parent,
messageStreamBlocks({
...baseContext,
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
disclosures: emptyDisclosures(),
forkActionsItemId: null,
loadOlderTurns: vi.fn(),
renderObsidianMarkdown: (element: HTMLElement, text: string) => {
markdownRenderer.renderObsidianMarkdown(element, text);
},
renderStreamMarkdown: vi.fn(),
items: [
{
id: "a1",
kind: "message",
role: "assistant",
text: "new",
text: "**done**",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "streaming",
messageState: "completed",
},
],
}),
);
await Promise.resolve();
expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("fresh:new");
firstRender.resolve(undefined);
await Promise.resolve();
await Promise.resolve();
expect(staleContent.isConnected).toBe(true);
expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("fresh:new");
expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("obsidian:**done**");
expect(renderMarkdown).toHaveBeenCalledOnce();
renderMarkdown.mockRestore();
unmountUiRootInAct(parent);
});
@ -772,8 +787,8 @@ describe("message stream rendering and message actions", () => {
vaultPath: "/vault",
});
markdownRenderer.renderMarkdown(parent, "old");
markdownRenderer.renderMarkdown(parent, "new");
markdownRenderer.renderObsidianMarkdown(parent, "old");
markdownRenderer.renderObsidianMarkdown(parent, "new");
await Promise.resolve();
expect(parent.textContent).toBe("fresh:new");

View file

@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TFile } from "obsidian";
import { StreamMarkdownMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/stream-markdown-renderer";
import { notices } from "../../../../mocks/obsidian";
import { installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
describe("StreamMarkdownMessageRenderer", () => {
beforeEach(() => {
notices.length = 0;
});
it("renders basic markdown through micromark", () => {
const renderer = streamMarkdownRenderer();
const parent = document.createElement("div");
renderer.renderStreamMarkdown(parent, "# Title\n\n**bold** text");
expect(parent.querySelector("h1")?.textContent).toBe("Title");
expect(parent.querySelector("strong")?.textContent).toBe("bold");
expect(parent.textContent).toBe("Title\nbold text");
});
it("leaves wikilinks as plain text while streaming", () => {
const renderer = streamMarkdownRenderer();
const parent = document.createElement("div");
renderer.renderStreamMarkdown(parent, "[[Project Note]]");
expect(parent.textContent).toBe("[[Project Note]]");
expect(parent.querySelector("a")).toBeNull();
});
it("opens safe vault file links through Obsidian", () => {
const openLinkText = vi.fn();
const renderer = streamMarkdownRenderer({ openLinkText, vaultPath: "/Users/showhey/Vault", vaultFiles: ["docs/Guide.md"] });
const parent = document.createElement("div");
renderer.renderStreamMarkdown(parent, "[Guide](/Users/showhey/Vault/docs/Guide.md)");
parent.querySelector<HTMLAnchorElement>("a")?.click();
expect(openLinkText).toHaveBeenCalledWith("docs/Guide.md", "Inbox.md", false);
});
it("does not open absolute file links outside the vault", () => {
const openLinkText = vi.fn();
const renderer = streamMarkdownRenderer({ openLinkText, vaultPath: "/Users/showhey/Vault" });
const parent = document.createElement("div");
renderer.renderStreamMarkdown(parent, "[Readme](/Users/showhey/Other/README.md)");
parent.querySelector<HTMLAnchorElement>("a")?.click();
expect(openLinkText).not.toHaveBeenCalled();
expect(notices).toEqual(["Cannot open files outside the vault."]);
});
it("prevents unresolved vault file links from navigating", () => {
const openLinkText = vi.fn();
const renderer = streamMarkdownRenderer({ openLinkText, vaultPath: "/Users/showhey/Vault" });
const parent = document.createElement("div");
renderer.renderStreamMarkdown(parent, "[Missing](/Users/showhey/Vault/docs/Missing.md)");
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
parent.querySelector<HTMLAnchorElement>("a")?.dispatchEvent(event);
expect(openLinkText).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
expect(notices).toEqual([]);
});
});
function streamMarkdownRenderer(options: { openLinkText?: ReturnType<typeof vi.fn>; vaultPath?: string; vaultFiles?: string[] } = {}) {
const files = new Map((options.vaultFiles ?? []).map((path) => [path, tFile(path)]));
return new StreamMarkdownMessageRenderer({
app: {
workspace: {
getActiveFile: vi.fn(() => tFile("Inbox.md")),
openLinkText: options.openLinkText ?? vi.fn(),
},
vault: {
configDir: "vault-config",
getAbstractFileByPath: (path: string) => files.get(path) ?? null,
},
} as never,
vaultPath: options.vaultPath ?? "/vault",
});
}
function tFile(path: string): TFile {
const basename = path.split("/").pop()?.replace(/\.md$/, "") ?? path;
return Object.assign(new TFile(), { path, basename });
}

View file

@ -59,8 +59,12 @@ function messageStreamBlockItemsEmpty(context: TestMessageStreamContext): boolea
return (context.stableItems?.length ?? 0) === 0 && (context.activeItems?.length ?? 0) === 0;
}
type TestMessageStreamContext = Omit<MessageStreamContext, "disclosures" | "forkActionsItemId"> &
Partial<Pick<MessageStreamContext, "disclosures" | "forkActionsItemId">> & {
type TestMessageStreamContext = Omit<
MessageStreamContext,
"disclosures" | "forkActionsItemId" | "renderObsidianMarkdown" | "renderStreamMarkdown"
> &
Partial<Pick<MessageStreamContext, "disclosures" | "forkActionsItemId" | "renderObsidianMarkdown" | "renderStreamMarkdown">> & {
renderMarkdown?: (parent: HTMLElement, text: string) => void;
turnLifecycle: MessageStreamTurnLifecycleState;
historyCursor: string | null;
loadingHistory: boolean;
@ -94,10 +98,19 @@ export function testDisclosures(
}
function normalizeMessageStreamContext(context: TestMessageStreamContext): MessageStreamContext {
const renderObsidianMarkdown =
context.renderObsidianMarkdown ??
context.renderMarkdown ??
((parent: HTMLElement, text: string) => {
parent.createDiv({ text });
});
const renderStreamMarkdown = context.renderStreamMarkdown ?? context.renderMarkdown ?? renderObsidianMarkdown;
return {
...context,
disclosures: context.disclosures ?? emptyDisclosures(),
forkActionsItemId: context.forkActionsItemId ?? null,
renderObsidianMarkdown,
renderStreamMarkdown,
};
}