diff --git a/package.json b/package.json index 6187a6e..8325a3d 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,14 @@ "axios": "^1.13.6", "element-plus": "^2.13.3", "html2pdf.js": "^0.14.0", + "markdown-it": "^14.2.0", "sass": "^1.97.3", "vue": "^3.5.25", "vue-router": "^4.6.4", "vuex": "^4.1.0" }, "devDependencies": { + "@types/markdown-it": "^14.1.2", "@types/node": "^24.10.1", "@vitejs/plugin-vue": "^6.0.2", "@vue/tsconfig": "^0.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a18fdf..b24d05d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: html2pdf.js: specifier: ^0.14.0 version: 0.14.0 + markdown-it: + specifier: ^14.2.0 + version: 14.2.0 sass: specifier: ^1.97.3 version: 1.97.3 @@ -33,6 +36,9 @@ importers: specifier: ^4.1.0 version: 4.1.0(vue@3.5.29(typescript@5.9.3)) devDependencies: + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 '@types/node': specifier: ^24.10.1 version: 24.11.0 @@ -487,12 +493,21 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@24.11.0': resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} @@ -587,6 +602,9 @@ packages: alien-signals@3.1.2: resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} @@ -658,6 +676,10 @@ packages: peerDependencies: vue: ^3.3.0 + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -783,6 +805,9 @@ packages: jspdf@4.2.1: resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + local-pkg@1.1.2: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} @@ -803,10 +828,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} @@ -870,6 +902,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -931,6 +967,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -1328,12 +1367,21 @@ snapshots: '@types/estree@1.0.8': {} + '@types/linkify-it@5.0.0': {} + '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.24 '@types/lodash@4.17.24': {} + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + '@types/node@24.11.0': dependencies: undici-types: 7.16.0 @@ -1460,6 +1508,8 @@ snapshots: alien-signals@3.1.2: {} + argparse@2.0.1: {} + async-validator@4.2.5: {} asynckit@0.4.0: {} @@ -1553,6 +1603,8 @@ snapshots: transitivePeerDependencies: - '@vue/composition-api' + entities@4.5.0: {} + entities@7.0.1: {} es-define-property@1.0.1: {} @@ -1702,6 +1754,10 @@ snapshots: dompurify: 3.3.3 html2canvas: 1.4.1 + linkify-it@5.0.1: + dependencies: + uc.micro: 2.1.0 + local-pkg@1.1.2: dependencies: mlly: 1.8.0 @@ -1722,8 +1778,19 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it@14.2.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.1 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + math-intrinsics@1.1.0: {} + mdurl@2.0.0: {} + memoize-one@6.0.0: {} mime-db@1.52.0: {} @@ -1783,6 +1850,8 @@ snapshots: proxy-from-env@1.1.0: {} + punycode.js@2.3.1: {} + quansync@0.2.11: {} raf@3.4.1: @@ -1864,6 +1933,8 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + ufo@1.6.3: {} undici-types@7.16.0: {} diff --git a/src/assets/styles/components/ai-chat.scss b/src/assets/styles/components/ai-chat.scss index a936e18..06ab267 100644 --- a/src/assets/styles/components/ai-chat.scss +++ b/src/assets/styles/components/ai-chat.scss @@ -94,6 +94,90 @@ font-size: 0.13rem; line-height: 1.6; max-width: 85%; + + // ===== Markdown 渲染样式 ===== + p { + margin: 0 0 0.08rem; + &:last-child { margin-bottom: 0; } + } + + h1, h2, h3, h4, h5, h6 { + margin: 0.12rem 0 0.06rem; + font-weight: 600; + line-height: 1.4; + } + h1 { font-size: 0.18rem; } + h2 { font-size: 0.16rem; } + h3 { font-size: 0.14rem; } + + ul, ol { + padding-left: 0.2rem; + margin: 0.06rem 0; + } + + li { + margin-bottom: 0.04rem; + } + + code { + background: #f1f5f9; + padding: 0.01rem 0.04rem; + border-radius: 0.03rem; + font-size: 0.12rem; + font-family: 'Courier New', monospace; + } + + pre { + background: #1e293b; + color: #e2e8f0; + padding: 0.1rem 0.12rem; + border-radius: 0.06rem; + overflow-x: auto; + margin: 0.08rem 0; + + code { + background: none; + padding: 0; + color: inherit; + font-size: 0.11rem; + } + } + + blockquote { + border-left: 3px solid #cbd5e1; + padding-left: 0.1rem; + margin: 0.08rem 0; + color: #64748b; + } + + a { + color: #2563eb; + text-decoration: underline; + } + + table { + border-collapse: collapse; + margin: 0.08rem 0; + width: 100%; + font-size: 0.12rem; + + th, td { + border: 1px solid #e5e7eb; + padding: 0.04rem 0.08rem; + text-align: left; + } + + th { + background: #f8fafc; + font-weight: 600; + } + } + + hr { + border: none; + border-top: 1px solid #e5e7eb; + margin: 0.1rem 0; + } } &__msg--ai &__msg-bubble { diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 1bce7f2..6538b3c 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -74,12 +74,22 @@ import { ref, computed, watch, nextTick, onMounted } from 'vue' import { useRoute } from 'vue-router' import { useStore } from 'vuex' +import markdownit from 'markdown-it' import MemberDialog from '@/components/MemberDialog.vue' import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue' import { sendNovaChat } from '@/utils/aiRequest' import type { NovaChatHistoryItem } from '@/utils/aiRequest' import { fetchResumeList } from '@/api/resume' +// ==================== Markdown 渲染实例 ==================== + +/** markdown-it 实例,用于将 AI 回复的 Markdown 文本渲染为 HTML */ +const md = markdownit({ + html: false, // 禁止原始 HTML(防 XSS) + breaks: true, // 将换行符转为
+ linkify: true, // 自动识别链接 +}) + // ==================== Props ==================== /** 组件属性 */ @@ -180,9 +190,9 @@ const userQuestions = computed(() => quickQuestions.value) // ==================== 内容格式化 ==================== -/** 将消息中的换行符转为 HTML 换行标签 */ +/** 将消息内容通过 markdown-it 渲染为 HTML */ function formatContent(content: string): string { - return content.replace(/\n/g, '
') + return md.render(content) } // ==================== 滚动控制 ==================== diff --git a/tsconfig.json b/tsconfig.json index dac5905..1412e6d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, + "allowSyntheticDefaultImports": true, "noEmit": true, "jsx": "preserve", "strict": true,