From 02a202290f7d498744f8511d8cebc8798c8c2c2a Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 3 Jun 2026 13:13:04 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=E6=9B=BF=E6=8D=A2=20Markdown?= =?UTF-8?q?=20=E6=B8=B2=E6=9F=93=E7=BB=84=E4=BB=B6=E4=B8=BA=20markdown-to-?= =?UTF-8?q?jsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 36 +------ docs/development/frontend.md | 2 +- package.json | 2 +- src/web/css.d.ts | 3 - .../features/chat/parts/CodeBlockWithCopy.tsx | 82 --------------- src/web/features/chat/parts/TextPart.tsx | 20 +--- src/web/styles.css | 26 ----- .../chat/CodeBlockWithCopy.test.tsx | 99 ------------------- 8 files changed, 8 insertions(+), 262 deletions(-) delete mode 100644 src/web/features/chat/parts/CodeBlockWithCopy.tsx delete mode 100644 tests/web/components/chat/CodeBlockWithCopy.test.tsx diff --git a/bun.lock b/bun.lock index 4f617f5..7b7f47d 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,6 @@ "@ai-sdk/react": "^3.0.195", "@ant-design/icons": "^6.2.3", "@ant-design/x": "^2.7.0", - "@ant-design/x-markdown": "^2.7.0", "@sinclair/typebox": "^0.34.49", "@tanstack/react-query": "^5.100.14", "ai": "^6.0.193", @@ -19,6 +18,7 @@ "antd": "^6.4.3", "drizzle-orm": "^0.45.2", "es-toolkit": "^1.47.0", + "markdown-to-jsx": "^9.8.1", "overlayscrollbars": "^2.16.0", "overlayscrollbars-react": "^0.5.6", "pino": "^10.3.1", @@ -91,8 +91,6 @@ "@ant-design/x": ["@ant-design/x@2.7.0", "", { "dependencies": { "@ant-design/colors": "^8.0.0", "@ant-design/cssinjs": "^2.0.1", "@ant-design/cssinjs-utils": "^2.0.2", "@ant-design/fast-color": "^3.0.0", "@ant-design/icons": "^6.0.0", "@babel/runtime": "^7.25.6", "@rc-component/motion": "^1.1.6", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "lodash.throttle": "^4.1.1", "mermaid": "^11.12.1", "react-syntax-highlighter": "^16.1.0" }, "peerDependencies": { "antd": "^6.1.1", "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-p5OtxQ9elbmeFRllGt1yj5wi6VHe41PIAmwrBU/OlaYydru5qIYsJzCS3DPRhkWkVdErU5oZwU74Z2oce2F5Uw=="], - "@ant-design/x-markdown": ["@ant-design/x-markdown@2.7.0", "", { "dependencies": { "clsx": "^2.1.1", "dompurify": "^3.2.6", "html-react-parser": "^5.2.13", "katex": "^0.16.22", "marked": "^15.0.12" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-tmuwbeulTD5nfO15VCb3mN13iCTT106626dVFxGjhj1tWnmLL+fIngyv3U8SOTq94+Baor6QdSWtPZluVKCIbw=="], - "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], @@ -839,16 +837,8 @@ "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], - - "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], - - "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="], - "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], - "dot-prop": ["dot-prop@5.3.0", "https://registry.npmmirror.com/dot-prop/-/dot-prop-5.3.0.tgz", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "drizzle-kit": ["drizzle-kit@0.31.10", "https://registry.npmmirror.com/drizzle-kit/-/drizzle-kit-0.31.10.tgz", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], @@ -1025,14 +1015,8 @@ "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], - "html-dom-parser": ["html-dom-parser@5.1.8", "", { "dependencies": { "domhandler": "5.0.3", "htmlparser2": "10.1.0" } }, "sha512-MCIUng//mF2qTtGHXJWr6OLfHWmg3Pm8ezpfiltF83tizPWY17JxT4dRLE8lykJ5bChJELoY3onQKPbufJHxYA=="], - "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], - "html-react-parser": ["html-react-parser@5.2.17", "", { "dependencies": { "domhandler": "5.0.3", "html-dom-parser": "5.1.8", "react-property": "2.0.2", "style-to-js": "1.1.21" }, "peerDependencies": { "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", "react": "0.14 || 15 || 16 || 17 || 18 || 19" }, "optionalPeers": ["@types/react"] }, "sha512-m+K/7Moq1jodAB4VL0RXSOmtwLUYoAsikZhwd+hGQe5Vtw2dbWfpFd60poxojMU0Tsh9w59mN1QLEcoHz0Dx9w=="], - - "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], - "husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1049,8 +1033,6 @@ "ini": ["ini@6.0.0", "https://registry.npmmirror.com/ini/-/ini-6.0.0.tgz", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], - "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "internal-slot": ["internal-slot@1.1.0", "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], @@ -1209,7 +1191,9 @@ "lz-string": ["lz-string@1.5.0", "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "markdown-to-jsx": ["markdown-to-jsx@9.8.1", "", { "peerDependencies": { "react": ">= 16.0.0", "react-native": "*", "solid-js": ">=1.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["react", "react-native", "solid-js", "vue"] }, "sha512-yq70dLPkBnE2LYFtGTLfRes4qyBDS+a4wDttAA/b/BzVGrbs2e0TfCeSFrMkapCg1lsxYi+42BowuBDxLP9k4Q=="], + + "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -1337,8 +1321,6 @@ "react-is": ["react-is@18.3.1", "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "react-property": ["react-property@2.0.2", "", {}, "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug=="], - "react-redux": ["react-redux@9.2.0", "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], "react-router": ["react-router@7.15.1", "https://registry.npmmirror.com/react-router/-/react-router-7.15.1.tgz", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="], @@ -1459,10 +1441,6 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-5.0.3.tgz", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], - - "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - "stylis": ["stylis@4.4.0", "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1643,8 +1621,6 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "eslint/ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -1655,8 +1631,6 @@ "eslint-plugin-import/minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "import-fresh/resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "is-bun-module/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], @@ -1665,8 +1639,6 @@ "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "pretty-format/react-is": ["react-is@17.0.2", "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], diff --git a/docs/development/frontend.md b/docs/development/frontend.md index 4c07c92..afbe9fa 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -37,7 +37,7 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si `ChatPage` = `Conversations`(@ant-design/x)+ `ChatPanel`。 - **Conversations**:会话侧边栏,TanStack Query 管理会话列表,支持创建/选中/删除(menu dropdown)。 -- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(XMarkdown)、ReasoningPart、ToolPart(四态)。支持编辑重发、重新生成、复制。 +- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx)、ReasoningPart、ToolPart(四态)。支持编辑重发、重新生成、复制。 - **Sender**(@ant-design/x):输入框 + 发送/停止按钮 + 模型 Select(footer slot)。 ## 共享代码 diff --git a/package.json b/package.json index 385aee4..4d7fa14 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@ai-sdk/react": "^3.0.195", "@ant-design/icons": "^6.2.3", "@ant-design/x": "^2.7.0", - "@ant-design/x-markdown": "^2.7.0", + "markdown-to-jsx": "^9.8.1", "@sinclair/typebox": "^0.34.49", "@tanstack/react-query": "^5.100.14", "ai": "^6.0.193", diff --git a/src/web/css.d.ts b/src/web/css.d.ts index 3cb4324..cbe652d 100644 --- a/src/web/css.d.ts +++ b/src/web/css.d.ts @@ -1,4 +1 @@ declare module "*.css"; -declare module "react-syntax-highlighter/dist/esm/styles/prism" { - export { oneDark, oneLight } from "react-syntax-highlighter"; -} diff --git a/src/web/features/chat/parts/CodeBlockWithCopy.tsx b/src/web/features/chat/parts/CodeBlockWithCopy.tsx deleted file mode 100644 index 75aba09..0000000 --- a/src/web/features/chat/parts/CodeBlockWithCopy.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { CopyOutlined } from "@ant-design/icons"; -import { CodeHighlighter } from "@ant-design/x"; -import { App, Button, Flex, Typography } from "antd"; -import React from "react"; -import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; - -import { useIsDark } from "../../../shared/hooks/use-is-dark"; - -type SyntaxTheme = Record>; - -const customOneDark: SyntaxTheme = { - ...(oneDark as SyntaxTheme), - 'pre[class*="language-"]': { - ...(oneDark as SyntaxTheme)['pre[class*="language-"]'], - margin: 0, - }, -}; - -const customOneLight: SyntaxTheme = { - ...(oneLight as SyntaxTheme), - 'pre[class*="language-"]': { - ...(oneLight as SyntaxTheme)['pre[class*="language-"]'], - margin: 0, - }, -}; - -interface CodeBlockWithCopyProps { - block?: boolean; - children?: React.ReactNode; - className?: string; - lang?: string; - streamStatus?: "done" | "loading"; -} - -export function CodeBlockWithCopy({ block, children, className, lang }: CodeBlockWithCopyProps) { - const { message } = App.useApp(); - const isDark = useIsDark(); - - if (!block) { - return {children}; - } - - const codeText = extractText(children); - const displayLang = lang ?? "plaintext"; - - const handleCopy = () => { - void navigator.clipboard.writeText(codeText).then(() => { - void message.success("已复制"); - }); - }; - - const header = ( - - - {displayLang} - - + {isLoading ? ( ) : ( { - void createConversation(project.id, defaultModelId ?? undefined) - .then((conv) => { - void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY }); - setActiveConversationId(conv.id); - }) - .catch((err: Error) => { - void message.error(`创建会话失败:${err.message}`); - }); - }, - }} items={conversations} menu={(conv) => ({ items: [ @@ -85,6 +92,7 @@ export function ChatPage() { trigger: , })} onActiveChange={(key) => setActiveConversationId(key)} + rootClassName="app-chat-conversations-list" /> )} diff --git a/src/web/styles.css b/src/web/styles.css index 57827b3..2f6c209 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -90,6 +90,16 @@ body { background: var(--ant-color-bg-container); } +.app-chat-conversations-header { + padding: var(--ant-padding-sm); + border-bottom: 1px solid var(--ant-color-border-secondary); +} + +.app-chat-conversations-list { + flex: 1; + min-height: 0; +} + .app-chat-panel { display: flex; flex: 1; From a896091d27182b4d37e854cdf8078598f5635dcd Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 3 Jun 2026 17:23:43 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Markdown=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=9D=97=E9=AB=98=E4=BA=AE=E5=92=8C=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 77 ++++++++++++++++ docs/development/frontend.md | 2 +- package.json | 3 +- src/web/features/chat/parts/CodeBlock.tsx | 84 +++++++++++++++++ src/web/features/chat/parts/MarkdownTable.tsx | 9 ++ src/web/features/chat/parts/TextPart.tsx | 15 ++- src/web/shared/hooks/use-theme-preference.ts | 22 ++++- src/web/styles.css | 71 ++++++++++++++ tests/web/components/chat/CodeBlock.test.tsx | 87 ++++++++++++++++++ .../components/chat/MarkdownTable.test.tsx | 43 +++++++++ tests/web/components/chat/TextPart.test.tsx | 92 +++++++++++++++++++ 11 files changed, 499 insertions(+), 6 deletions(-) create mode 100644 src/web/features/chat/parts/CodeBlock.tsx create mode 100644 src/web/features/chat/parts/MarkdownTable.tsx create mode 100644 tests/web/components/chat/CodeBlock.test.tsx create mode 100644 tests/web/components/chat/MarkdownTable.test.tsx create mode 100644 tests/web/components/chat/TextPart.test.tsx diff --git a/bun.lock b/bun.lock index 7b7f47d..b8ab3b5 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "react-dom": "^19.2.6", "react-router": "^7.15.1", "recharts": "^3.8.1", + "shiki": "^4.2.0", "zod": "^4.4.3", }, "devDependencies": { @@ -431,6 +432,22 @@ "@rtsao/scc": ["@rtsao/scc@1.1.0", "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + "@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], + + "@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], + + "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], + + "@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], + + "@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "https://registry.npmmirror.com/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="], "@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "https://registry.npmmirror.com/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], @@ -535,6 +552,8 @@ "@types/json5": ["@types/json5@0.0.29", "https://registry.npmmirror.com/@types/json5/-/json5-0.0.29.tgz", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/node": ["@types/node@25.6.2", "https://registry.npmmirror.com/@types/node/-/node-25.6.2.tgz", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], @@ -571,6 +590,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], @@ -679,8 +700,12 @@ "caniuse-lite": ["caniuse-lite@1.0.30001792", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], @@ -833,6 +858,8 @@ "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "doctrine": ["doctrine@2.1.0", "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], @@ -1003,6 +1030,10 @@ "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "help-me": ["help-me@5.0.0", "https://registry.npmmirror.com/help-me/-/help-me-5.0.0.tgz", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], @@ -1017,6 +1048,8 @@ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1197,12 +1230,24 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mdn-data": ["mdn-data@2.27.1", "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "meow": ["meow@13.2.0", "https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "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.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "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" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mimic-function": ["mimic-function@5.0.1", "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -1243,6 +1288,10 @@ "onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + "optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "overlayscrollbars": ["overlayscrollbars@2.16.0", "", {}, "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg=="], @@ -1339,6 +1388,12 @@ "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1395,6 +1450,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], + "side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.1", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -1435,6 +1492,8 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strip-bom": ["strip-bom@3.0.0", "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], @@ -1471,6 +1530,8 @@ "tr46": ["tr46@6.0.0", "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], @@ -1501,6 +1562,16 @@ "undici-types": ["undici-types@7.25.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.25.0.tgz", {}, "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "unrs-resolver": ["unrs-resolver@1.11.1", "https://registry.npmmirror.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -1511,6 +1582,10 @@ "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], "vite": ["vite@8.0.14", "https://registry.npmmirror.com/vite/-/vite-8.0.14.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], @@ -1559,6 +1634,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/core/json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], diff --git a/docs/development/frontend.md b/docs/development/frontend.md index afbe9fa..f6d91b8 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -37,7 +37,7 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si `ChatPage` = `Conversations`(@ant-design/x)+ `ChatPanel`。 - **Conversations**:会话侧边栏,TanStack Query 管理会话列表,支持创建/选中/删除(menu dropdown)。 -- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx)、ReasoningPart、ToolPart(四态)。支持编辑重发、重新生成、复制。 +- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx 含自定义 overrides:CodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式)、ReasoningPart、ToolPart(四态)。支持编辑重发、重新生成、复制。 - **Sender**(@ant-design/x):输入框 + 发送/停止按钮 + 模型 Select(footer slot)。 ## 共享代码 diff --git a/package.json b/package.json index 4d7fa14..82cfc32 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "@ai-sdk/react": "^3.0.195", "@ant-design/icons": "^6.2.3", "@ant-design/x": "^2.7.0", - "markdown-to-jsx": "^9.8.1", "@sinclair/typebox": "^0.34.49", "@tanstack/react-query": "^5.100.14", "ai": "^6.0.193", @@ -67,6 +66,7 @@ "antd": "^6.4.3", "drizzle-orm": "^0.45.2", "es-toolkit": "^1.47.0", + "markdown-to-jsx": "^9.8.1", "overlayscrollbars": "^2.16.0", "overlayscrollbars-react": "^0.5.6", "pino": "^10.3.1", @@ -76,6 +76,7 @@ "react-dom": "^19.2.6", "react-router": "^7.15.1", "recharts": "^3.8.1", + "shiki": "^4.2.0", "zod": "^4.4.3" } } diff --git a/src/web/features/chat/parts/CodeBlock.tsx b/src/web/features/chat/parts/CodeBlock.tsx new file mode 100644 index 0000000..016a783 --- /dev/null +++ b/src/web/features/chat/parts/CodeBlock.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from "react"; + +import { CopyOutlined } from "@ant-design/icons"; +import { Button, message } from "antd"; +import { useCallback, useEffect, useState } from "react"; +import { codeToHtml } from "shiki"; + +import { useIsDark } from "../../../shared/hooks/use-is-dark"; + +interface CodeBlockProps { + children: ReactNode; + className?: string; + isStreaming: boolean; +} + +export function CodeBlock({ children, className: _className, isStreaming }: CodeBlockProps) { + const isDark = useIsDark(); + const [highlighted, setHighlighted] = useState(null); + const { codeText, lang } = extractCode(children); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(codeText).then( + () => message.success("已复制"), + () => message.error("复制失败"), + ); + }, [codeText]); + + useEffect(() => { + if (isStreaming || !codeText) return; + + let cancelled = false; + codeToHtml(codeText, { + lang, + theme: isDark ? "github-dark" : "github-light", + }) + .then((html) => { + if (!cancelled) setHighlighted(html); + }) + .catch(() => { + if (!cancelled) setHighlighted(null); + }); + + return () => { + cancelled = true; + }; + }, [codeText, lang, isDark, isStreaming]); + + if (isStreaming) { + return ( +
+        {codeText}
+      
+ ); + } + + return ( +
+
+ {lang} +
+ {highlighted ? ( +
+ ) : ( +
+          {codeText}
+        
+ )} +
+ ); +} + +function extractCode(children: ReactNode): { codeText: string; lang: string } { + if (children && typeof children === "object" && "props" in children && children.props) { + const props = children.props as Record; + const codeText = typeof props["children"] === "string" ? props["children"] : ""; + const codeClassName = typeof props["className"] === "string" ? props["className"] : ""; + const classes = codeClassName.split(/\s+/); + const langClass = classes.find((c) => c.startsWith("lang-") || c.startsWith("language-")) ?? ""; + const lang = langClass.replace(/^(language|lang)-/, "") || "text"; + return { codeText, lang }; + } + return { codeText: typeof children === "string" ? children : "", lang: "text" }; +} diff --git a/src/web/features/chat/parts/MarkdownTable.tsx b/src/web/features/chat/parts/MarkdownTable.tsx new file mode 100644 index 0000000..bb09113 --- /dev/null +++ b/src/web/features/chat/parts/MarkdownTable.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +interface MarkdownTableProps { + children: ReactNode; +} + +export function MarkdownTable({ children }: MarkdownTableProps) { + return {children}
; +} diff --git a/src/web/features/chat/parts/TextPart.tsx b/src/web/features/chat/parts/TextPart.tsx index fa4da16..102d910 100644 --- a/src/web/features/chat/parts/TextPart.tsx +++ b/src/web/features/chat/parts/TextPart.tsx @@ -3,6 +3,9 @@ import Markdown from "markdown-to-jsx/react"; import type { PartProps } from "./types"; +import { CodeBlock } from "./CodeBlock"; +import { MarkdownTable } from "./MarkdownTable"; + interface TextPartProps extends PartProps { isStreaming: boolean; role: string; @@ -16,7 +19,17 @@ export function TextPart({ isStreaming, part, role }: TextPartProps) { {role === "user" ? ( {text} ) : ( - {text} + + {text} + )}
); diff --git a/src/web/shared/hooks/use-theme-preference.ts b/src/web/shared/hooks/use-theme-preference.ts index 40fa7ef..1f6e74a 100644 --- a/src/web/shared/hooks/use-theme-preference.ts +++ b/src/web/shared/hooks/use-theme-preference.ts @@ -1,8 +1,10 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; export type EffectiveTheme = "dark" | "light"; export type ThemePreference = "dark" | "light" | "system"; +const PREFERENCE_CHANGE_EVENT = "theme-preference-change"; + export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference"; export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)"; @@ -44,10 +46,19 @@ export function useThemePreference() { return () => mediaQueryList.removeEventListener("change", handleChange); }, []); - const setPreference = (nextPreference: ThemePreference) => { + useEffect(() => { + const handleStorageEvent = (event: CustomEvent) => { + const next = parseThemePreference(event.detail); + setPreferenceState((prev) => (prev !== next ? next : prev)); + }; + window.addEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener); + return () => window.removeEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener); + }, []); + + const setPreference = useCallback((nextPreference: ThemePreference) => { setPreferenceState(nextPreference); writeThemePreference(nextPreference); - }; + }, []); return { effectiveTheme, preference, setPreference }; } @@ -58,4 +69,9 @@ export function writeThemePreference(preference: ThemePreference, storage: Stora } catch { // 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。 } + try { + window.dispatchEvent(new CustomEvent(PREFERENCE_CHANGE_EVENT, { detail: preference })); + } catch { + // jsdom 等环境可能不支持 CustomEvent + } } diff --git a/src/web/styles.css b/src/web/styles.css index 2f6c209..235d31f 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -291,3 +291,74 @@ body { .app-inbox-datepicker { width: 100%; } + +/* Markdown 代码块 */ +.code-block { + margin: var(--ant-margin-sm) 0; + border: 1px solid var(--ant-color-border-secondary); + border-radius: var(--ant-border-radius-lg); + overflow: hidden; + font-family: var(--ant-font-family-code, monospace); + font-size: var(--ant-font-size-sm); +} + +.code-block-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--ant-padding-xxs) var(--ant-padding-sm); + background: var(--ant-color-bg-container); + border-bottom: 1px solid var(--ant-color-border-secondary); +} + +.code-block-lang { + color: var(--ant-color-text-quaternary); + font-size: var(--ant-font-size-xs); + text-transform: lowercase; +} + +.code-block-body { + margin: 0; + padding: var(--ant-padding-sm); + overflow-x: auto; + background: var(--ant-color-bg-container); +} + +.code-block-body code { + font-family: inherit; + font-size: inherit; +} + +/* Markdown 表格 */ +.markdown-table { + width: 100%; + margin: var(--ant-margin-sm) 0; + border-collapse: separate; + border-spacing: 0; + border: 1px solid var(--ant-color-border-secondary); + border-radius: var(--ant-border-radius-lg); + overflow: hidden; + font-size: var(--ant-font-size); +} + +.markdown-table th, +.markdown-table td { + padding: var(--ant-padding-xs) var(--ant-padding-sm); + border-bottom: 1px solid var(--ant-color-border-secondary); + text-align: left; +} + +.markdown-table thead th { + background: var(--ant-color-bg-container); + color: var(--ant-color-text); + font-weight: 600; + border-bottom: 1px solid var(--ant-color-border-secondary); +} + +.markdown-table tbody tr:last-child td { + border-bottom: none; +} + +.markdown-table tbody tr:hover td { + background: var(--ant-color-fill-quaternary); +} diff --git a/tests/web/components/chat/CodeBlock.test.tsx b/tests/web/components/chat/CodeBlock.test.tsx new file mode 100644 index 0000000..ecf93b8 --- /dev/null +++ b/tests/web/components/chat/CodeBlock.test.tsx @@ -0,0 +1,87 @@ +import { screen, waitFor } from "@testing-library/react"; +import { describe, expect, mock, test } from "bun:test"; +import { createElement } from "react"; + +import { renderWithProviders } from "../../test-utils"; + +void mock.module("../../../../src/web/shared/hooks/use-is-dark", () => ({ + useIsDark: () => false, +})); + +void mock.module("shiki", () => ({ + codeToHtml: (code: string, options: { lang: string; theme: string }) => + Promise.resolve(`
${code}
`), +})); + +import { CodeBlock } from "../../../../src/web/features/chat/parts/CodeBlock"; + +function createCodeChild(text: string, lang = "javascript"): React.ReactElement { + return createElement("code", { className: `language-${lang}` }, text); +} + +describe("CodeBlock 流式期间纯文本渲染", () => { + test("流式时渲染为纯文本 pre", () => { + const child = createCodeChild("const x = 1;"); + + renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: true })); + + const pre = document.querySelector(".code-block"); + expect(pre).toBeTruthy(); + expect(pre!.tagName).toBe("PRE"); + expect(screen.getByText("const x = 1;")).toBeTruthy(); + expect(document.querySelector(".code-block-header")).toBeNull(); + }); +}); + +describe("CodeBlock 非流式高亮渲染", () => { + test("非流式时显示语言标签和复制按钮", () => { + const child = createCodeChild("const x = 1;"); + + renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: false })); + + expect(document.querySelector(".code-block-header")).toBeTruthy(); + expect(document.querySelector(".code-block-lang")).toBeTruthy(); + expect(screen.getByText("javascript")).toBeTruthy(); + }); + + test("非流式时异步高亮代码", async () => { + const child = createCodeChild("const x = 1;"); + + renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: false })); + + await waitFor(() => { + const body = document.querySelector(".code-block-body"); + expect(body).toBeTruthy(); + expect(body!.innerHTML).toContain("const x = 1;"); + }); + }); +}); + +describe("CodeBlock 复制按钮", () => { + test("复制按钮存在且可点击", () => { + const child = createCodeChild("test code"); + + renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: false })); + + const button = document.querySelector(".code-block-header button"); + expect(button).toBeTruthy(); + }); +}); + +describe("CodeBlock 语言标签显示", () => { + test("常见语言显示正确", () => { + const child = createCodeChild("print('hello')", "python"); + + renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: false })); + + expect(screen.getByText("python")).toBeTruthy(); + }); + + test("无语言时显示 text", () => { + const child = createElement("code", { className: "" }, "plain text"); + + renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: false })); + + expect(screen.getByText("text")).toBeTruthy(); + }); +}); diff --git a/tests/web/components/chat/MarkdownTable.test.tsx b/tests/web/components/chat/MarkdownTable.test.tsx new file mode 100644 index 0000000..69da80d --- /dev/null +++ b/tests/web/components/chat/MarkdownTable.test.tsx @@ -0,0 +1,43 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, test } from "bun:test"; +import { createElement } from "react"; + +import { MarkdownTable } from "../../../../src/web/features/chat/parts/MarkdownTable"; +import { renderWithProviders } from "../../test-utils"; + +describe("MarkdownTable 渲染表格", () => { + test("渲染原生 table 元素并添加 class", () => { + const children = createElement( + "thead", + null, + createElement("tr", null, createElement("th", null, "列1"), createElement("th", null, "列2")), + ); + + renderWithProviders(createElement(MarkdownTable, { children })); + + const table = document.querySelector(".markdown-table"); + expect(table).toBeTruthy(); + expect(table!.tagName).toBe("TABLE"); + expect(screen.getByText("列1")).toBeTruthy(); + expect(screen.getByText("列2")).toBeTruthy(); + }); + + test("正确传递 children 内容", () => { + const children = createElement( + "tbody", + null, + createElement("tr", null, createElement("td", null, "值1"), createElement("td", null, "值2")), + ); + + renderWithProviders(createElement(MarkdownTable, { children })); + + expect(screen.getByText("值1")).toBeTruthy(); + expect(screen.getByText("值2")).toBeTruthy(); + }); + + test("只有传入 table 元素时才有 class", () => { + const { container } = renderWithProviders(createElement(MarkdownTable, { children: null })); + + expect(container.querySelector(".markdown-table")).toBeTruthy(); + }); +}); diff --git a/tests/web/components/chat/TextPart.test.tsx b/tests/web/components/chat/TextPart.test.tsx new file mode 100644 index 0000000..e4fb752 --- /dev/null +++ b/tests/web/components/chat/TextPart.test.tsx @@ -0,0 +1,92 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, test } from "bun:test"; +import { createElement } from "react"; + +import { TextPart } from "../../../../src/web/features/chat/parts/TextPart"; +import { renderWithProviders } from "../../test-utils"; + +function createTextPart(text: string, _isStreaming = false, _role = "assistant"): Record { + return { text, type: "text" }; +} + +describe("TextPart AI 消息含 Markdown 渲染", () => { + test("代码块使用 pre override 渲染", () => { + const part = createTextPart("```javascript\nconst x = 1;\n```"); + + renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "assistant" })); + + expect(screen.getByText("const x = 1;")).toBeTruthy(); + expect(document.querySelector(".code-block")).toBeTruthy(); + }); + + test("表格使用 markdown-table class 渲染", () => { + const part = createTextPart("| A | B |\n| --- | --- |\n| 1 | 2 |"); + + renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "assistant" })); + + expect(screen.getByText("A")).toBeTruthy(); + expect(document.querySelector(".markdown-table")).toBeTruthy(); + }); + + test("表格单元格内富文本正常渲染", () => { + const part = createTextPart("| A | B |\n| --- | --- |\n| **粗体** | `code` |"); + + renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "assistant" })); + + const bold = screen.getByText("粗体"); + expect(bold).toBeTruthy(); + expect(bold.tagName).toBe("STRONG"); + expect(screen.getByText("code")).toBeTruthy(); + }); +}); + +describe("TextPart 用户消息不受 overrides 影响", () => { + test("用户消息不应用 Markdown 渲染", () => { + const part = createTextPart("```javascript\nconst x = 1;\n```"); + + const { container } = renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "user" })); + + expect(container.textContent).toContain("const x = 1;"); + expect(document.querySelector(".code-block")).toBeNull(); + }); + + test("用户消息使用 Typography.Paragraph 渲染", () => { + const part = createTextPart("普通文本消息"); + + renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "user" })); + + expect(document.querySelector(".message-body-text")).toBeTruthy(); + }); +}); + +describe("TextPart 流式状态传递", () => { + test("流式状态下 isStreaming 传递给 CodeBlock", () => { + const part = createTextPart("```javascript\nconst x = 1;\n```"); + + renderWithProviders(createElement(TextPart, { isStreaming: true, part, role: "assistant" })); + + const codeBlock = document.querySelector(".code-block"); + expect(codeBlock).toBeTruthy(); + expect(codeBlock!.tagName).toBe("PRE"); + }); + + test("非流式状态下渲染完整代码块结构", () => { + const part = createTextPart("```javascript\nconst x = 1;\n```"); + + renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "assistant" })); + + expect(document.querySelector(".code-block-header")).toBeTruthy(); + }); +}); + +describe("TextPart 纯文本 AI 消息", () => { + test("无代码块无表格的消息正常渲染", () => { + const part = createTextPart("这是一条普通的 AI 回复。"); + + const { container } = renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "assistant" })); + + expect(screen.getByText("这是一条普通的 AI 回复。")).toBeTruthy(); + expect(container.querySelector(".code-block")).toBeNull(); + expect(container.querySelector(".markdown-table")).toBeNull(); + }); +});