|
@@ -8,6 +8,7 @@ import robotBubbleLight from './image/robotBubbleLight.png'
|
|
|
import robotBubbleDark from './image/robotBubbleDark.png'
|
|
import robotBubbleDark from './image/robotBubbleDark.png'
|
|
|
import { useThemeStore } from '@/stores/modules/theme'
|
|
import { useThemeStore } from '@/stores/modules/theme'
|
|
|
import MarkdownIt from 'markdown-it'
|
|
import MarkdownIt from 'markdown-it'
|
|
|
|
|
+import 'github-markdown-css/github-markdown.css'
|
|
|
|
|
|
|
|
const md = new MarkdownIt({
|
|
const md = new MarkdownIt({
|
|
|
html: true,
|
|
html: true,
|
|
@@ -15,15 +16,10 @@ const md = new MarkdownIt({
|
|
|
typographer: true,
|
|
typographer: true,
|
|
|
breaks: true
|
|
breaks: true
|
|
|
})
|
|
})
|
|
|
-const mdContent = `
|
|
|
|
|
-## 测试标题
|
|
|
|
|
|
|
|
|
|
-这是一个 **粗体** 和 *斜体* 的例子。
|
|
|
|
|
-
|
|
|
|
|
-点击这个 [链接](https://example.com) 查看详情。
|
|
|
|
|
-
|
|
|
|
|
-`
|
|
|
|
|
-const renderedMessage = computed(() => md.render(mdContent))
|
|
|
|
|
|
|
+const renderedMessage = (content) => {
|
|
|
|
|
+ return md.render(content)
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
const themeStore = useThemeStore()
|
|
const themeStore = useThemeStore()
|
|
|
const modalSize = ref('large')
|
|
const modalSize = ref('large')
|
|
@@ -38,6 +34,7 @@ const robotBubbleImg = computed(() => {
|
|
|
return themeStore.theme === 'light' ? robotBubbleLight : robotBubbleDark
|
|
return themeStore.theme === 'light' ? robotBubbleLight : robotBubbleDark
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+const loadingAnswer = ref(false) // 是否正在加载答案
|
|
|
interface MessageItem {
|
|
interface MessageItem {
|
|
|
type: 'robot' | 'user'
|
|
type: 'robot' | 'user'
|
|
|
content: string
|
|
content: string
|
|
@@ -68,6 +65,15 @@ const messages = ref<MessageItem[]>([
|
|
|
}
|
|
}
|
|
|
])
|
|
])
|
|
|
|
|
|
|
|
|
|
+watch(
|
|
|
|
|
+ () => messages.value,
|
|
|
|
|
+ (newVal) => {
|
|
|
|
|
+ // 将消息存储到 sessionStorage 中
|
|
|
|
|
+ sessionStorage.setItem('AIChat', JSON.stringify(newVal))
|
|
|
|
|
+ },
|
|
|
|
|
+ { immediate: true, deep: true }
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
// 用户问题请求时间
|
|
// 用户问题请求时间
|
|
|
const queryTime = ref(-1)
|
|
const queryTime = ref(-1)
|
|
|
// 当前用户问题回复进度
|
|
// 当前用户问题回复进度
|
|
@@ -99,6 +105,67 @@ const handleSend = (question, isPresetQuestion = true) => {
|
|
|
queryTime.value++
|
|
queryTime.value++
|
|
|
}, 1000)
|
|
}, 1000)
|
|
|
}
|
|
}
|
|
|
|
|
+const showScrollButton = ref(false) // 控制按钮显示
|
|
|
|
|
+const messagesRef = ref()
|
|
|
|
|
+const autoScroll = ref(true)
|
|
|
|
|
+
|
|
|
|
|
+function handleScroll() {
|
|
|
|
|
+ const el = messagesRef.value
|
|
|
|
|
+ const threshold = 50 // 到底部的距离阈值
|
|
|
|
|
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold
|
|
|
|
|
+ autoScroll.value = atBottom
|
|
|
|
|
+
|
|
|
|
|
+ showScrollButton.value = el.scrollHeight > el.clientHeight && !autoScroll.value
|
|
|
|
|
+}
|
|
|
|
|
+function scrollToBottom() {
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ if (messagesRef.value) {
|
|
|
|
|
+ messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const simulateStreamingMarkdown = () => {
|
|
|
|
|
+ const chunks = [
|
|
|
|
|
+ '# 欢迎使用 Markdown\n\n',
|
|
|
|
|
+ '这是一个用于测试的 **Markdown** 文本。\n\n',
|
|
|
|
|
+ '你可以使用 *斜体* 来强调重点。\n\n',
|
|
|
|
|
+ '也可以使用 [链接](https://example.com) 引导用户跳转。\n\n',
|
|
|
|
|
+ '- 支持无序列表\n',
|
|
|
|
|
+ '- 非常适合逐行流式显示\n\n',
|
|
|
|
|
+ '这是一段测试!\n'
|
|
|
|
|
+ ]
|
|
|
|
|
+ const clientHeight = messagesRef.value.getBoundingClientRect()
|
|
|
|
|
+ console.log(messagesRef.value.offsetHeight, 'messagesRef.value', clientHeight)
|
|
|
|
|
+
|
|
|
|
|
+ loadingAnswer
|
|
|
|
|
+ messages.value.push({
|
|
|
|
|
+ type: 'robot',
|
|
|
|
|
+ content: '',
|
|
|
|
|
+ isAnswer: true,
|
|
|
|
|
+ feedback: '',
|
|
|
|
|
+ isShowFeedback: false
|
|
|
|
|
+ })
|
|
|
|
|
+ loadingAnswer.value = true
|
|
|
|
|
+ let index = 0
|
|
|
|
|
+
|
|
|
|
|
+ if (autoScroll.value) {
|
|
|
|
|
+ scrollToBottom()
|
|
|
|
|
+ }
|
|
|
|
|
+ const interval = setInterval(() => {
|
|
|
|
|
+ index === chunks.length - 1 ? (loadingAnswer.value = false) : ''
|
|
|
|
|
+ if (index < chunks.length) {
|
|
|
|
|
+ messages.value[messages.value.length - 1].content += chunks[index]
|
|
|
|
|
+ index++
|
|
|
|
|
+
|
|
|
|
|
+ if (autoScroll.value) {
|
|
|
|
|
+ scrollToBottom()
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ clearInterval(interval)
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 500) // 每500ms追加一段
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
// 根据时间更新消息内容
|
|
// 根据时间更新消息内容
|
|
|
const updateMessageContent = (time) => {
|
|
const updateMessageContent = (time) => {
|
|
@@ -169,6 +236,7 @@ const handleClose = () => {
|
|
|
<div
|
|
<div
|
|
|
class="chat-messages"
|
|
class="chat-messages"
|
|
|
ref="messagesRef"
|
|
ref="messagesRef"
|
|
|
|
|
+ @scroll="handleScroll"
|
|
|
:style="{ marginTop: modalSize === 'large' ? '81px' : '151px' }"
|
|
:style="{ marginTop: modalSize === 'large' ? '81px' : '151px' }"
|
|
|
>
|
|
>
|
|
|
<div
|
|
<div
|
|
@@ -185,10 +253,10 @@ const handleClose = () => {
|
|
|
@mouseleave="msg.isShowFeedback = false"
|
|
@mouseleave="msg.isShowFeedback = false"
|
|
|
>
|
|
>
|
|
|
<!-- 请求失败后的提示icon -->
|
|
<!-- 请求失败后的提示icon -->
|
|
|
- <div
|
|
|
|
|
|
|
+ <span
|
|
|
v-if="queryTime === -3 && index === messages.length - 1"
|
|
v-if="queryTime === -3 && index === messages.length - 1"
|
|
|
class="pause-icon font_family icon-icon_warning_fill_b"
|
|
class="pause-icon font_family icon-icon_warning_fill_b"
|
|
|
- ></div>
|
|
|
|
|
|
|
+ ></span>
|
|
|
<!-- loading icon -->
|
|
<!-- loading icon -->
|
|
|
<img
|
|
<img
|
|
|
class="loading-img"
|
|
class="loading-img"
|
|
@@ -198,8 +266,10 @@ const handleClose = () => {
|
|
|
/>
|
|
/>
|
|
|
<span v-if="!msg.isAnswer">{{ msg.content }}</span>
|
|
<span v-if="!msg.isAnswer">{{ msg.content }}</span>
|
|
|
<div v-else>
|
|
<div v-else>
|
|
|
- <div v-html="renderedMessage" class="markdown-body"></div>
|
|
|
|
|
- <LoadingDots></LoadingDots>
|
|
|
|
|
|
|
+ <div v-html="renderedMessage(msg.content)" class="markdown-body"></div>
|
|
|
|
|
+ <LoadingDots
|
|
|
|
|
+ v-if="index === messages.length - 1 && msg.isAnswer && loadingAnswer"
|
|
|
|
|
+ ></LoadingDots>
|
|
|
<div></div>
|
|
<div></div>
|
|
|
</div>
|
|
</div>
|
|
|
<!-- 评价 -->
|
|
<!-- 评价 -->
|
|
@@ -243,6 +313,10 @@ const handleClose = () => {
|
|
|
<div class="dot"></div>
|
|
<div class="dot"></div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <!-- 滚动到底部icon -->
|
|
|
|
|
+ <div v-if="showScrollButton" class="scroll-to-bottom-btn" @click="scrollToBottom">
|
|
|
|
|
+ <span class="font_family icon-icon_movedown_b"></span>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="footer-input" :class="[isFooterInputFocus ? 'focus-style' : '']">
|
|
<div class="footer-input" :class="[isFooterInputFocus ? 'focus-style' : '']">
|
|
@@ -252,10 +326,11 @@ const handleClose = () => {
|
|
|
@focus="isFooterInputFocus = true"
|
|
@focus="isFooterInputFocus = true"
|
|
|
@blur="isFooterInputFocus = false"
|
|
@blur="isFooterInputFocus = false"
|
|
|
/>
|
|
/>
|
|
|
|
|
+ <el-button @click="simulateStreamingMarkdown">测试</el-button>
|
|
|
<div
|
|
<div
|
|
|
class="input-icon"
|
|
class="input-icon"
|
|
|
:class="[userQuestion ? 'input-style' : 'disable']"
|
|
:class="[userQuestion ? 'input-style' : 'disable']"
|
|
|
- @click="handleSend(userQuestion.value, false)"
|
|
|
|
|
|
|
+ @click="handleSend(userQuestion, false)"
|
|
|
>
|
|
>
|
|
|
<span class="font_family icon-icon_send_b"></span>
|
|
<span class="font_family icon-icon_send_b"></span>
|
|
|
</div>
|
|
</div>
|
|
@@ -314,7 +389,7 @@ const handleClose = () => {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
flex-direction: column;
|
|
|
gap: 16px;
|
|
gap: 16px;
|
|
|
- padding: 0 16px;
|
|
|
|
|
|
|
+ padding: 0 16px 16px;
|
|
|
overflow: auto;
|
|
overflow: auto;
|
|
|
.message-item {
|
|
.message-item {
|
|
|
position: relative;
|
|
position: relative;
|
|
@@ -372,7 +447,9 @@ const handleClose = () => {
|
|
|
margin-right: 4px;
|
|
margin-right: 4px;
|
|
|
animation: loading-rotate 2s linear infinite;
|
|
animation: loading-rotate 2s linear infinite;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
|
|
+ .markdown-body {
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ }
|
|
|
.pause-btn {
|
|
.pause-btn {
|
|
|
position: absolute;
|
|
position: absolute;
|
|
|
right: -22px;
|
|
right: -22px;
|
|
@@ -393,7 +470,9 @@ const handleClose = () => {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
.query-style {
|
|
.query-style {
|
|
|
- color: #b5b9bf;
|
|
|
|
|
|
|
+ span {
|
|
|
|
|
+ color: #b5b9bf;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
.robot-bubble {
|
|
.robot-bubble {
|
|
|
background: var(--scoring-bg-color);
|
|
background: var(--scoring-bg-color);
|
|
@@ -417,6 +496,24 @@ const handleClose = () => {
|
|
|
bottom: -7px;
|
|
bottom: -7px;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ .scroll-to-bottom-btn {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ right: 50%;
|
|
|
|
|
+ transform: translateX(50%);
|
|
|
|
|
+ bottom: 52px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ height: 32px;
|
|
|
|
|
+ width: 32px;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ background-color: var(--color-customize-column-right-section-bg);
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ &:hover {
|
|
|
|
|
+ background-color: var(--color-theme);
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
.footer-input {
|
|
.footer-input {
|
|
|
display: flex;
|
|
display: flex;
|