添加 StreamMarkdownRender 组件并更新相关依赖,优化聊天界面
This commit is contained in:
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -14,6 +14,7 @@ declare module 'vue' {
|
||||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
StreamMarkdownRender: typeof import('./src/components/StreamMarkdownRender.vue')['default']
|
||||
SvgIcon: typeof import('./src/components/SvgIcon.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.15",
|
||||
"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",
|
||||
"tailwindcss": "^4.1.15",
|
||||
"vue": "^3.5.22",
|
||||
@@ -25,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.18.6",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
|
||||
79
pnpm-lock.yaml
generated
79
pnpm-lock.yaml
generated
@@ -14,6 +14,15 @@ importers:
|
||||
ant-design-vue:
|
||||
specifier: ~4.2.6
|
||||
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:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
|
||||
@@ -30,6 +39,9 @@ importers:
|
||||
'@tsconfig/node22':
|
||||
specifier: ^22.0.2
|
||||
version: 22.0.2
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.2
|
||||
'@types/node':
|
||||
specifier: ^22.18.6
|
||||
version: 22.18.12
|
||||
@@ -710,6 +722,15 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
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':
|
||||
resolution: {integrity: sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==}
|
||||
|
||||
@@ -1612,6 +1633,10 @@ packages:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
@@ -1968,6 +1993,9 @@ packages:
|
||||
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
@@ -2007,6 +2035,13 @@ packages:
|
||||
resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==}
|
||||
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:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2014,6 +2049,9 @@ packages:
|
||||
mdn-data@2.0.14:
|
||||
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
|
||||
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
memorystream@0.3.1:
|
||||
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
@@ -2270,6 +2308,10 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2622,6 +2664,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
ufo@1.6.1:
|
||||
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
|
||||
|
||||
@@ -3382,6 +3427,15 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -4508,6 +4562,8 @@ snapshots:
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
htmlparser2@3.10.1:
|
||||
@@ -4814,6 +4870,10 @@ snapshots:
|
||||
lightningcss-win32-arm64-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:
|
||||
dependencies:
|
||||
big.js: 5.2.2
|
||||
@@ -4854,10 +4914,25 @@ snapshots:
|
||||
dependencies:
|
||||
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: {}
|
||||
|
||||
mdn-data@2.0.14: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
memorystream@0.3.1: {}
|
||||
|
||||
merge-options@1.0.1:
|
||||
@@ -5129,6 +5204,8 @@ snapshots:
|
||||
|
||||
prettier@3.6.2: {}
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
quansync@0.2.11: {}
|
||||
@@ -5578,6 +5655,8 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
ufo@1.6.1: {}
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- core-js
|
||||
- esbuild
|
||||
|
||||
426
src/components/StreamMarkdownRender.vue
Normal file
426
src/components/StreamMarkdownRender.vue
Normal 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>
|
||||
@@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, computed } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 定义组件 Props (父组件可以传入的参数)
|
||||
@@ -16,16 +16,16 @@ import { defineProps, computed } from 'vue'
|
||||
const props = defineProps({
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'icon'
|
||||
default: 'icon',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
customCss: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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">
|
||||
<!-- 用户提问消息(靠右) -->
|
||||
@@ -15,42 +15,55 @@
|
||||
<div v-else class="flex mb-4">
|
||||
<!-- 头像 -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 回复的内容 -->
|
||||
<div class="p-1 max-w-[80%] mb-2">
|
||||
<p>{{ chat.content }}</p>
|
||||
<div class="p-1 mb-2 max-w-[90%]">
|
||||
<StreamMarkdownRender :content="chat.content" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</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">
|
||||
<textarea v-model="message" placeholder="给小维 AI 机器人发送消息"
|
||||
class="bg-transparent border-none outline-none w-full text-sm resize-none min-h-[24px]" rows="2"
|
||||
@input="autoResize" ref="textareaRef">
|
||||
<textarea
|
||||
v-model="message"
|
||||
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>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@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>
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { onBeforeUnmount, ref, nextTick } from 'vue'
|
||||
import StreamMarkdownRender from '@/components/StreamMarkdownRender.vue'
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -60,15 +73,22 @@ interface ChatMessage {
|
||||
// 输入的消息
|
||||
const message = ref<string>('')
|
||||
|
||||
// 聊天容器引用
|
||||
const chatContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// textarea引用
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// 当前 SSE 连接实例
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
// 聊天记录 (给个默认的问候语)
|
||||
const chatList = ref<ChatMessage[]>([
|
||||
{ role: 'assistant', content: '我是小维智能 AI 助手!✨ 我可以帮你解答各种问题,无论是学习、工作,还是日常生活中的小困惑,都可以找我聊聊。有什么我可以帮你的吗?😊' }
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
'我是小维智能 AI 助手!✨ 我可以帮你解答各种问题,无论是学习、工作,还是日常生活中的小困惑,都可以找我聊聊。有什么我可以帮你的吗?😊',
|
||||
},
|
||||
])
|
||||
|
||||
// 发送消息
|
||||
@@ -103,7 +123,9 @@ const sendMessage = async (): Promise<void> => {
|
||||
|
||||
try {
|
||||
// 建立 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 = ''
|
||||
@@ -111,12 +133,16 @@ const sendMessage = async (): Promise<void> => {
|
||||
// 处理消息事件
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log('接收到数据: ', event.data)
|
||||
if (event.data) { // 若响应数据不为空
|
||||
if (event.data) {
|
||||
// 若响应数据不为空
|
||||
// 持续追加流式回答
|
||||
responseText += event.data;
|
||||
const response = JSON.parse(event.data)
|
||||
responseText += response.v
|
||||
|
||||
// 更新最后一条消息
|
||||
updateAssistantMessage(responseText)
|
||||
// 滚动到底部
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +154,8 @@ const sendMessage = async (): Promise<void> => {
|
||||
} else {
|
||||
// 提示用户 “请求出错”
|
||||
updateAssistantMessage('抱歉,请求出错了,请稍后重试。')
|
||||
// 滚动到底部
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
// 关闭 SSE
|
||||
@@ -138,8 +166,9 @@ const sendMessage = async (): Promise<void> => {
|
||||
// 提示用户 “请求出错”
|
||||
updateAssistantMessage('抱歉,请求出错了,请稍后重试。')
|
||||
closeSSE()
|
||||
// 滚动到底部
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 关闭 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(() => {
|
||||
closeSSE()
|
||||
@@ -157,12 +197,19 @@ onBeforeUnmount(() => {
|
||||
|
||||
// 自动调整文本域高度
|
||||
const autoResize = () => {
|
||||
const textarea = textareaRef.value;
|
||||
if (textarea) { // 若文本域存在
|
||||
textarea.style.height = 'auto'; // 1. 先将高度重置为 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'; // 2. 再设置为内容的实际高度
|
||||
const textarea = textareaRef.value
|
||||
if (textarea) {
|
||||
// 重置高度以获取正确的滚动高度
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -179,4 +226,8 @@ const autoResize = () => {
|
||||
max-width: calc(100% - 48px);
|
||||
position: relative;
|
||||
}
|
||||
/* 聊天内容区域样式 */
|
||||
.overflow-y-auto {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 自定义滚动条颜色 */
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user