添加 StreamMarkdownRender 组件并更新相关依赖,优化聊天界面

This commit is contained in:
2025-10-23 14:26:53 +08:00
parent 9c69404bf9
commit 7317ac4864
7 changed files with 608 additions and 46 deletions

1
components.d.ts vendored
View File

@@ -14,6 +14,7 @@ declare module 'vue' {
AButton: typeof import('ant-design-vue/es')['Button'] AButton: typeof import('ant-design-vue/es')['Button']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
StreamMarkdownRender: typeof import('./src/components/StreamMarkdownRender.vue')['default']
SvgIcon: typeof import('./src/components/SvgIcon.vue')['default'] SvgIcon: typeof import('./src/components/SvgIcon.vue')['default']
} }
} }

View File

@@ -18,6 +18,9 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.15", "@tailwindcss/vite": "^4.1.15",
"ant-design-vue": "~4.2.6", "ant-design-vue": "~4.2.6",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"markdown-it-highlightjs": "^4.2.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"tailwindcss": "^4.1.15", "tailwindcss": "^4.1.15",
"vue": "^3.5.22", "vue": "^3.5.22",
@@ -25,6 +28,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "^22.0.2", "@tsconfig/node22": "^22.0.2",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.18.6", "@types/node": "^22.18.6",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",

79
pnpm-lock.yaml generated
View File

@@ -14,6 +14,15 @@ importers:
ant-design-vue: ant-design-vue:
specifier: ~4.2.6 specifier: ~4.2.6
version: 4.2.6(vue@3.5.22(typescript@5.9.3)) version: 4.2.6(vue@3.5.22(typescript@5.9.3))
highlight.js:
specifier: ^11.11.1
version: 11.11.1
markdown-it:
specifier: ^14.1.0
version: 14.1.0
markdown-it-highlightjs:
specifier: ^4.2.0
version: 4.2.0
pinia: pinia:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)) version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
@@ -30,6 +39,9 @@ importers:
'@tsconfig/node22': '@tsconfig/node22':
specifier: ^22.0.2 specifier: ^22.0.2
version: 22.0.2 version: 22.0.2
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
'@types/node': '@types/node':
specifier: ^22.18.6 specifier: ^22.18.6
version: 22.18.12 version: 22.18.12
@@ -710,6 +722,15 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@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@22.18.12': '@types/node@22.18.12':
resolution: {integrity: sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==} resolution: {integrity: sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==}
@@ -1612,6 +1633,10 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true hasBin: true
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hookable@5.5.3: hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -1968,6 +1993,9 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
loader-utils@1.4.2: loader-utils@1.4.2:
resolution: {integrity: sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==} resolution: {integrity: sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
@@ -2007,6 +2035,13 @@ packages:
resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==} resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
markdown-it-highlightjs@4.2.0:
resolution: {integrity: sha512-NC7pXE8KkOl6xWJVRNt8p6wgJVznXKsE0HgYGdk6DD2tn1l4L9f0ALf3VIoGVkotNU1uGQatSxfBF1zZPUMmuQ==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2014,6 +2049,9 @@ packages:
mdn-data@2.0.14: mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
memorystream@0.3.1: memorystream@0.3.1:
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
engines: {node: '>= 0.10.0'} engines: {node: '>= 0.10.0'}
@@ -2270,6 +2308,10 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2622,6 +2664,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ufo@1.6.1: ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
@@ -3382,6 +3427,15 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {}
'@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@22.18.12': '@types/node@22.18.12':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -4508,6 +4562,8 @@ snapshots:
he@1.2.0: {} he@1.2.0: {}
highlight.js@11.11.1: {}
hookable@5.5.3: {} hookable@5.5.3: {}
htmlparser2@3.10.1: htmlparser2@3.10.1:
@@ -4814,6 +4870,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
loader-utils@1.4.2: loader-utils@1.4.2:
dependencies: dependencies:
big.js: 5.2.2 big.js: 5.2.2
@@ -4854,10 +4914,25 @@ snapshots:
dependencies: dependencies:
object-visit: 1.0.1 object-visit: 1.0.1
markdown-it-highlightjs@4.2.0:
dependencies:
highlight.js: 11.11.1
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
mdn-data@2.0.14: {} mdn-data@2.0.14: {}
mdurl@2.0.0: {}
memorystream@0.3.1: {} memorystream@0.3.1: {}
merge-options@1.0.1: merge-options@1.0.1:
@@ -5129,6 +5204,8 @@ snapshots:
prettier@3.6.2: {} prettier@3.6.2: {}
punycode.js@2.3.1: {}
punycode@2.3.1: {} punycode@2.3.1: {}
quansync@0.2.11: {} quansync@0.2.11: {}
@@ -5578,6 +5655,8 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
uc.micro@2.1.0: {}
ufo@1.6.1: {} ufo@1.6.1: {}
unbox-primitive@1.1.0: unbox-primitive@1.1.0:

View File

@@ -1,2 +1,3 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- core-js
- esbuild - esbuild

View File

@@ -0,0 +1,426 @@
<template>
<div class="markdown-container">
<div v-html="renderedContent"></div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import MarkdownIt from 'markdown-it'
import markdownItHighlightJs from 'markdown-it-highlightjs'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css' // 引入代码高亮样式
type CopyCodeHandler = (codeId: string) => Promise<void>
// 初始化复制功能
const setupCopyFunction = () => {
// 确保全局复制函数只定义一次
const globalWindow = window as typeof window & { copyCode?: CopyCodeHandler }
if (!globalWindow.copyCode) {
// 定义全局复制函数
globalWindow.copyCode = async (codeId: string) => {
try {
// 1. 获取目标代码元素
const codeElement = document.getElementById(codeId)
if (!(codeElement instanceof HTMLElement)) return // 元素不存在则退出
// 2. 获取待复制的代码内容
// 从元素的 data-code 属性获取 URL 编码的代码内容并解码
const encodedCode = codeElement.getAttribute('data-code')
if (!encodedCode) return
// 3. 写入剪贴板
const codeContent = decodeURIComponent(encodedCode)
await navigator.clipboard.writeText(codeContent)
// 显示复制成功反馈
const btn = codeElement.parentElement?.querySelector<HTMLButtonElement>('.copy-code-btn')
const iconWrapper = btn?.querySelector<SVGElement>('.copy-icon')
const originalIcon = iconWrapper?.innerHTML ?? ''
if (btn) {
// 保存原始图标 SVG
if (iconWrapper) {
// 替换为对号图标
iconWrapper.innerHTML =
`<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474c-6.1-7.7-15.3-12.2-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9L357.1 864c12.6 16.1 35.5 16.1 48.1 0L918.3 202.9c4.1-5.2 0.4-12.9-6.3-12.9z" p-id="4582"></path>`
}
// 添加复制成功状态类
btn.classList.add('copied')
}
message.success('复制成功')
if (btn) {
// 1秒后恢复原始图标
setTimeout(() => {
if (iconWrapper) {
iconWrapper.innerHTML = originalIcon
}
btn.classList.remove('copied')
}, 1000)
}
} catch (err) {
console.error('复制失败:', err)
}
}
}
}
// 定义一个 content 字段,用于父组件传入 markdown 文本
const props = withDefaults(
defineProps<{
content: string
}>(),
{
content: '',
},
)
// 解析后的 HTML
const renderedContent = ref('')
// 初始化 MarkdownIt
const md = new MarkdownIt({
html: true, // 允许解析 HTML 标签
xhtmlOut: true, // 输出符合 XHTML 规范的标签(如 `<br />` 而不是 `<br>`)。默认 false。
linkify: true, // 自动将文本中的 URL 转换为可点击的链接
typographer: true, // 启用排版优化
breaks: true, // 将单个换行符 (\n) 转换为 <br>
langPrefix: 'language-', // 代码块的语言类名前缀(默认 'language-')。例如 ```js 会生成 <pre><code class="language-js">
})
// 使用代码高亮插件
md.use(markdownItHighlightJs, {
hljs,
auto: true, // 自动检测语言
code: true, // 高亮内联代码
})
type FenceRenderRule = NonNullable<InstanceType<typeof MarkdownIt>['renderer']['rules']['fence']>
// 保存默认的代码块渲染规则
const fallbackRender: FenceRenderRule = (tokens, idx, options, env, renderer) =>
renderer.renderToken(tokens, idx, options)
const defaultRender: FenceRenderRule = md.renderer.rules.fence ?? fallbackRender
// 重写 Markdown 渲染器的代码块渲染规则
const customFenceRule: FenceRenderRule = (tokens, idx, options, env, renderer) => {
// 获取当前索引对应的 token代码块
const token = tokens[idx]
if (!token) {
return defaultRender(tokens, idx, options, env, renderer)
}
// 处理语言信息:移除转义字符并去除首尾空格
const info = token.info ? md.utils.unescapeAll(token.info).trim() : ''
let langName = ''
// 如果存在语言信息
if (info) {
// 分割信息字符串
const [langCode = ''] = info.split(/\s+/g)
langName = langCode.toLowerCase() // 转换为小写统一格式
}
// 使用默认渲染器生成原始 HTML 内容
const originalContent = defaultRender(tokens, idx, options, env, renderer)
// 拼装最终的 HTML
let finalContent = `<div class="code-block-wrapper">
<div class="code-header">
`
// 如果有返回代码块语言信息,需要显示
if (langName) {
finalContent += `<div class="code-language-label">${langName}</div>`
}
// 代码块中的实际代码
const codeContent = token.content
// 为每个代码块分配一个唯一标识,方便知道复制的哪个代码块中的内容
const codeId = `code-${Math.random().toString(36).substr(2, 9)}`
// 返回渲染结果
return (finalContent += `
<button class="copy-code-btn" onclick="copyCode('${codeId}')">
<svg t="1750068080826" class="copy-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1529"
width="15" height="15"><path d="M761.088 715.3152a38.7072 38.7072 0 0 1 0-77.4144 37.4272 37.4272 0 0 0 37.4272-37.4272V265.0112a37.4272 37.4272 0 0 0-37.4272-37.4272H425.6256a37.4272 37.4272 0 0 0-37.4272 37.4272 38.7072 38.7072 0 1 1-77.4144 0 115.0976 115.0976 0 0 1 114.8416-114.8416h335.4624a115.0976 115.0976 0 0 1 114.8416 114.8416v335.4624a115.0976 115.0976 0 0 1-114.8416 114.8416z" p-id="1530"></path><path d="M589.4656 883.0976H268.1856a121.1392 121.1392 0 0 1-121.2928-121.2928v-322.56a121.1392 121.1392 0 0 1 121.2928-121.344h321.28a121.1392 121.1392 0 0 1 121.2928 121.2928v322.56c1.28 67.1232-54.1696 121.344-121.2928 121.344zM268.1856 395.3152a43.52 43.52 0 0 0-43.8784 43.8784v322.56a43.52 43.52 0 0 0 43.8784 43.8784h321.28a43.52 43.52 0 0 0 43.8784-43.8784v-322.56a43.52 43.52 0 0 0-43.8784-43.8784z" p-id="1531"></path></svg>
<span class="copy-text">复制</span>
</button>
</div>
<div class="code-content" id="${codeId}" data-code="${encodeURIComponent(codeContent)}">
${originalContent}
</div>
</div>`)
}
md.renderer.rules.fence = customFenceRule
// 监听 content 字段,流式更新处理
watch(
() => props.content,
(newVal) => {
if (newVal) {
// 渲染为 HTML
const html = md.render(newVal)
renderedContent.value = html
// 确保复制功能在 DOM 更新后可用
nextTick(() => {
setupCopyFunction()
})
}
},
{ immediate: true },
)
</script>
<style scoped>
.markdown-container {
width: 100%;
line-height: 24px;
color: rgb(64 64 64);
}
/* 第一个 p 标签的上边距设置为0 */
:deep(.markdown-container > p:first-child),
:deep(p:first-child) {
margin-top: 0;
}
/* Markdown 转换为 HTML 的样式 */
/* 修复标题选择器 - 使用逗号分隔多个选择器 */
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
font-weight: 600;
margin: calc(1.143 * 16px) 0 calc(1.143 * 12px) 0;
}
:deep(h1) {
font-size: 1.5em;
margin-top: 1.2em;
margin-bottom: 0.7em;
line-height: 1.5;
}
:deep(h2) {
font-size: 1.3em;
margin-top: 1.1em;
margin-bottom: 0.6em;
line-height: 1.5;
}
:deep(h3) {
font-size: calc(1.143 * 16px);
line-height: 1.5;
}
:deep(p) {
line-height: 1.7;
margin: calc(1.143 * 12px) 0;
font-size: calc(1.143 * 14px);
}
:deep(ul) {
list-style: disc; /* 实心圆点 */
margin-top: 0.6em;
margin-bottom: 0.9em;
padding-left: 2em;
}
:deep(ol) {
list-style: decimal;
margin-top: 0.6em;
margin-bottom: 0.9em;
padding-left: 2em;
}
/* 列表项样式 */
:deep(li) {
margin-bottom: 0.5em;
line-height: 1.7;
}
/* 修复列表标记样式 */
:deep(ol li::marker) {
line-height: calc(1.143 * 25px);
color: rgb(139 139 139);
}
:deep(ul li::marker) {
color: rgb(139 139 139);
}
/* 嵌套列表样式 */
:deep(ul ul) {
list-style: circle;
margin-top: 0.3em;
margin-bottom: 0.3em;
}
:deep(ul ul ul) {
list-style: square; /* 三级列表使用方块 */
}
:deep(pre) {
background-color: #fafafa;
padding: 1em;
border-radius: 5px;
overflow-x: auto;
max-width: 100%; /* 确保不超过容器宽度 */
white-space: pre; /* 保持原始格式 */
word-wrap: normal; /* 不在单词内部换行 */
}
/* 单独的 code 标签样式 - 不在 pre 内的code */
:deep(:not(pre) > code) {
font-size: 0.875em;
font-weight: 600;
background-color: #ececec;
border-radius: 4px;
padding: 0.15rem 0.3rem;
margin: 0 0.2rem;
}
/* pre 内的 code 标签样式 */
:deep(pre > code) {
font-size: 0.875em;
background-color: transparent;
padding: 0;
border-radius: 0;
font-weight: normal;
color: #333;
display: block;
width: 100%;
}
:deep(a) {
color: #4d6bfe;
text-decoration: none;
}
:deep(a:hover) {
text-decoration: underline;
}
:deep(blockquote) {
border-left: 4px solid #e5e5e5;
padding-left: 1em;
margin: 1em 0;
color: #666;
}
:deep(table) {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 0.95em;
}
:deep(th),
:deep(td) {
border: 1px solid #e5e5e5;
padding: 0.6em;
text-align: left;
}
:deep(th) {
background-color: #f5f5f5;
}
:deep(hr) {
background-color: rgb(229 229 229);
margin: 1.5em 0;
height: 1px;
border: none;
}
/* 确保相邻元素之间的间距一致且适当 */
:deep(h1 + p),
:deep(h2 + p),
:deep(h3 + p) {
margin-top: 0.5em;
}
:deep(p + ul),
:deep(p + ol) {
margin-top: 0.5em;
}
:deep(ul + p),
:deep(ol + p) {
margin-top: 0.7em;
}
/* 代码块包装器样式 */
:deep(.code-block-wrapper) {
margin: 1em 0;
border-radius: 14px;
overflow: hidden;
background-color: #f6f8fa;
}
/* 代码块头部样式 */
:deep(.code-header) {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #f5f5f5;
padding: 8px 12px;
}
/* 语言标签样式 */
:deep(.code-language-label) {
color: rgb(82 82 82);
margin-left: 8px;
font-size: 12px;
line-height: 18px;
}
/* 代码高亮样式优化 */
:deep(.hljs) {
background: transparent !important;
padding: 0 !important;
}
/* 复制按钮样式 */
:deep(.copy-code-btn) {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
border-radius: 12px;
padding: 0 8px;
color: #586069;
font-size: 12px;
height: 28px;
cursor: pointer;
transition: all 0.2s ease;
}
:deep(.copy-code-btn.copied .copy-icon) {
fill: #22c55e;
}
:deep(.copy-code-btn:hover) {
background-color: rgb(0 0 0 / 4%);
}
:deep(.copy-icon) {
fill: currentColor;
flex-shrink: 0;
}
:deep(.copy-text) {
white-space: nowrap;
}
</style>

View File

@@ -5,7 +5,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineProps, computed } from 'vue' import { computed } from 'vue'
/** /**
* 定义组件 Props (父组件可以传入的参数) * 定义组件 Props (父组件可以传入的参数)
@@ -16,16 +16,16 @@ import { defineProps, computed } from 'vue'
const props = defineProps({ const props = defineProps({
prefix: { prefix: {
type: String, type: String,
default: 'icon' default: 'icon',
}, },
name: { name: {
type: String, type: String,
required: true required: true,
}, },
customCss: { customCss: {
type: String, type: String,
default: '' default: '',
} },
}) })
/** /**

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="h-screen max-w-3xl mx-auto relative"> <div class="h-screen flex flex-col overflow-y-auto" ref="chatContainer">
<!-- 聊天记录区域 --> <!-- 聊天记录区域 -->
<div class="overflow-y-auto pb-24 pt-4 px-4"> <div class="flex-1 max-w-3xl mx-auto pb-24 pt-4 px-4">
<!-- 遍历聊天记录 --> <!-- 遍历聊天记录 -->
<template v-for="(chat, index) in chatList" :key="index"> <template v-for="(chat, index) in chatList" :key="index">
<!-- 用户提问消息靠右 --> <!-- 用户提问消息靠右 -->
@@ -15,42 +15,55 @@
<div v-else class="flex mb-4"> <div v-else class="flex mb-4">
<!-- 头像 --> <!-- 头像 -->
<div class="shrink-0 mr-3"> <div class="shrink-0 mr-3">
<div class="w-8 h-8 rounded-full flex items-center justify-center border border-gray-200"> <div
class="w-8 h-8 rounded-full flex items-center justify-center border border-gray-200"
>
<SvgIcon name="deepseek-logo" customCss="w-5 h-5"></SvgIcon> <SvgIcon name="deepseek-logo" customCss="w-5 h-5"></SvgIcon>
</div> </div>
</div> </div>
<!-- 回复的内容 --> <!-- 回复的内容 -->
<div class="p-1 max-w-[80%] mb-2"> <div class="p-1 mb-2 max-w-[90%]">
<p>{{ chat.content }}</p> <StreamMarkdownRender :content="chat.content" />
</div> </div>
</div> </div>
</template> </template>
</div> </div>
<!-- 提问输入框 --> <!-- 提问输入框 -->
<div class="absolute bottom-0 left-0 w-full mb-5"> <div class="sticky max-w-3xl mx-auto bg-white bottom-0 left-0 w-full">
<div class="bg-gray-100 rounded-3xl px-4 py-3 mx-4 border border-gray-200 flex flex-col"> <div class="bg-gray-100 rounded-3xl px-4 py-3 mx-4 border border-gray-200 flex flex-col">
<textarea v-model="message" placeholder="给小维 AI 机器人发送消息" <textarea
class="bg-transparent border-none outline-none w-full text-sm resize-none min-h-[24px]" rows="2" v-model="message"
@input="autoResize" ref="textareaRef"> placeholder="给小维 AI 机器人发送消息"
class="bg-transparent border-none outline-none w-full text-sm resize-none min-h-6"
rows="2"
@input="autoResize"
ref="textareaRef"
@keydown.enter.prevent="sendMessage"
>
</textarea> </textarea>
<!-- 发送按钮 --> <!-- 发送按钮 -->
<div class="flex justify-end"> <div class="flex justify-end">
<button <button
@click="sendMessage" @click="sendMessage"
class="flex items-center justify-center bg-[#4d6bfe] rounded-full w-8 h-8 border border-[#4d6bfe] hover:bg-[#3b5bef] transition-colors"> :disabled="!message.trim()"
class="flex items-center justify-center bg-[#4d6bfe] rounded-full w-8 h-8 border border-[#4d6bfe] hover:bg-[#3b5bef] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<SvgIcon name="up-arrow" customCss="w-5 h-5 text-white"></SvgIcon> <SvgIcon name="up-arrow" customCss="w-5 h-5 text-white"></SvgIcon>
</button> </button>
</div> </div>
</div> </div>
<!-- 提示文字 --> <!-- 提示文字 -->
<div class="flex items-center justify-center text-xs text-gray-400 mt-2">内容由 AI 生成请仔细甄别</div> <div class="flex items-center justify-center text-xs text-gray-400 mt-2">
内容由 AI 生成请仔细甄别
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue' import { onBeforeUnmount, ref, nextTick } from 'vue'
import StreamMarkdownRender from '@/components/StreamMarkdownRender.vue'
interface ChatMessage { interface ChatMessage {
role: 'user' | 'assistant' role: 'user' | 'assistant'
@@ -60,15 +73,22 @@ interface ChatMessage {
// 输入的消息 // 输入的消息
const message = ref<string>('') const message = ref<string>('')
// 聊天容器引用
const chatContainer = ref<HTMLElement | null>(null)
// textarea引用 // textarea引用
const textareaRef = ref<HTMLTextAreaElement | null>(null); const textareaRef = ref<HTMLTextAreaElement | null>(null)
// 当前 SSE 连接实例 // 当前 SSE 连接实例
let eventSource: EventSource | null = null let eventSource: EventSource | null = null
// 聊天记录 (给个默认的问候语) // 聊天记录 (给个默认的问候语)
const chatList = ref<ChatMessage[]>([ const chatList = ref<ChatMessage[]>([
{ role: 'assistant', content: '我是小维智能 AI 助手!✨ 我可以帮你解答各种问题,无论是学习、工作,还是日常生活中的小困惑,都可以找我聊聊。有什么我可以帮你的吗?😊' } {
role: 'assistant',
content:
'我是小维智能 AI 助手!✨ 我可以帮你解答各种问题,无论是学习、工作,还是日常生活中的小困惑,都可以找我聊聊。有什么我可以帮你的吗?😊',
},
]) ])
// 发送消息 // 发送消息
@@ -103,7 +123,9 @@ const sendMessage = async (): Promise<void> => {
try { try {
// 建立 SSE 连接 // 建立 SSE 连接
eventSource = new EventSource(`http://localhost:8080/v5/ai/generateStream?message=${encodeURIComponent(userMessage)}`) eventSource = new EventSource(
`http://localhost:8080/v6/ai/generateStream?message=${encodeURIComponent(userMessage)}`,
)
// 响应的回答 // 响应的回答
let responseText = '' let responseText = ''
@@ -111,12 +133,16 @@ const sendMessage = async (): Promise<void> => {
// 处理消息事件 // 处理消息事件
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
console.log('接收到数据: ', event.data) console.log('接收到数据: ', event.data)
if (event.data) { // 若响应数据不为空 if (event.data) {
// 若响应数据不为空
// 持续追加流式回答 // 持续追加流式回答
responseText += event.data; const response = JSON.parse(event.data)
responseText += response.v
// 更新最后一条消息 // 更新最后一条消息
updateAssistantMessage(responseText) updateAssistantMessage(responseText)
// 滚动到底部
scrollToBottom()
} }
} }
@@ -128,6 +154,8 @@ const sendMessage = async (): Promise<void> => {
} else { } else {
// 提示用户 “请求出错” // 提示用户 “请求出错”
updateAssistantMessage('抱歉,请求出错了,请稍后重试。') updateAssistantMessage('抱歉,请求出错了,请稍后重试。')
// 滚动到底部
scrollToBottom()
} }
// 关闭 SSE // 关闭 SSE
@@ -138,8 +166,9 @@ const sendMessage = async (): Promise<void> => {
// 提示用户 “请求出错” // 提示用户 “请求出错”
updateAssistantMessage('抱歉,请求出错了,请稍后重试。') updateAssistantMessage('抱歉,请求出错了,请稍后重试。')
closeSSE() closeSSE()
// 滚动到底部
scrollToBottom()
} }
} }
// 关闭 SSE 连接 // 关闭 SSE 连接
@@ -150,6 +179,17 @@ const closeSSE = () => {
} }
} }
// 滚动到底部
const scrollToBottom = async () => {
await nextTick() // 等待 Vue.js 完成 DOM 更新
if (chatContainer.value) {
// 若容器存在
// 将容器的滚动条位置设置到最底部
const container = chatContainer.value
container.scrollTop = container.scrollHeight
}
}
// 组件卸载时自动关闭连接 // 组件卸载时自动关闭连接
onBeforeUnmount(() => { onBeforeUnmount(() => {
closeSSE() closeSSE()
@@ -157,12 +197,19 @@ onBeforeUnmount(() => {
// 自动调整文本域高度 // 自动调整文本域高度
const autoResize = () => { const autoResize = () => {
const textarea = textareaRef.value; const textarea = textareaRef.value
if (textarea) { // 若文本域存在 if (textarea) {
textarea.style.height = 'auto'; // 1. 先将高度重置为 'auto' // 重置高度以获取正确的滚动高度
textarea.style.height = textarea.scrollHeight + 'px'; // 2. 再设置为内容的实际高度 textarea.style.height = 'auto'
// 计算新高度,但最大不超过 300px
const newHeight = Math.min(textarea.scrollHeight, 300)
textarea.style.height = newHeight + 'px'
// 如果内容超出 300px则启用滚动
textarea.style.overflowY = textarea.scrollHeight > 300 ? 'auto' : 'hidden'
}
} }
};
</script> </script>
<style scoped> <style scoped>
@@ -179,4 +226,8 @@ const autoResize = () => {
max-width: calc(100% - 48px); max-width: calc(100% - 48px);
position: relative; position: relative;
} }
/* 聊天内容区域样式 */
.overflow-y-auto {
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 自定义滚动条颜色 */
}
</style> </style>